Skip to content

필사 모드: Concurrent Login Prevention Implementation Guide — Layer-by-Layer Strategies with IP, Session, JWT, and Nginx Including Practical Code

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

Introduction

The need to prevent simultaneous logins from multiple locations with a single account arises more often than expected. Financial services, paid streaming, B2B SaaS, and internal admin tools are just some of the domains where a "one account = one session" policy is essential.

This article compares four approaches to implementing **Concurrent Session Control** from an architectural perspective, explaining **what to check at each layer** along with **practical code and operational know-how** all in one place.

1. Approach Comparison

1-1. IP-Based Restriction

The simplest method is to "block when the same account connects from different IPs."

**Pros**

- Implementation is very simple -- record the IP on login and compare against request IP.

- Can work without a dedicated storage.

**Cons -- False positives are severe**

| Scenario | Result |

| ----------------------------------------------------------- | ---------------------------------------------------------------- |

| Corporate NAT environment -- hundreds sharing one public IP | Misjudges different users as the same user |

| Mobile network switching (Wi-Fi to LTE) | Legitimate users force-logged out due to IP change |

| Proxy/VPN users | Multiple users from same IP, or one user's IP frequently changes |

| CGNAT (carrier shared IP) | Completely unrelated users share the same IP |

> **Conclusion**: IP-based restriction should only be used as an **auxiliary signal** and is unsuitable as a standalone policy. It is effective for brute-force detection like "multiple account login attempts from the same IP in a short time."

1-2. Session-Based Single Login

This approach manages active sessions per user in a server session store (usually Redis).

**Flow**

1. Login → Create session → Redis: user:{userId}:sessions = {sessionId}

2. New login occurs → Look up existing session ID → Invalidate existing session (delete)

3. Request from old device → No session → 401 + "Logged in from another location" message

**Pros**

- Server has full control over session lifecycle.

- Immediate forced logout is possible.

- Maximum concurrent session count can be flexibly set to N.

**Cons**

- Server-side session store (Redis, etc.) is required.

- May conflict with stateless architecture.

- Session store failure affects all authentication.

1-3. JWT-Based Single Login

To control concurrent logins while using stateless JWT, a strategy that **maintains minimal server-side state** is needed.

**Key Techniques**

| Technique | Description |

| ------------------------------ | ----------------------------------------------------------------------------------- |

| `jti` (JWT ID) based blocklist | Add previous token's jti to blocklist on login. Check blocklist during verification |

| `session_version` | session_version column in user table - increment on login - compare with JWT claim |

| `device_id` binding | Include device-specific ID in JWT, manage allowed device list on server |

| Refresh Token Rotation | Revoke existing refresh token family on new login - unable to refresh access token |

**Refresh Token Rotation Flow**

1. Login → Issue access_token + refresh_token

2. Save refresh_token to server DB/Redis (user_id → token_family)

3. New device login → Issue new token_family + revoke entire existing family

4. Old device attempts refresh → Family is revoked → 401 + prompt re-login

> **Key Point**: Even with JWT, server-side storage of refresh tokens is practically essential. Concurrent login control is impossible with completely stateless architecture.

1-4. Device Binding / Risk-Based Authentication

This approach creates a **device profile** by combining User-Agent, IP, browser fingerprint, and other signals, requiring additional authentication for logins from unknown devices.

- **Available signals**: User-Agent, IP range, screen resolution, timezone, installed fonts, Canvas/WebGL fingerprint

- **Caution**: Browser fingerprinting has **privacy invasion concerns**, so it should only be collected with consent. Always verify legal requirements such as GDPR and privacy protection laws.

- **Recommended pattern**: Use in the form of **"email/SMS secondary authentication on login from new device"** rather than forced blocking.

2. Comprehensive Approach Comparison

| Criteria | IP-Based | Session-Based | JWT + Refresh Rotation | Device Binding |

| ------------------------- | ----------------------------- | ----------------- | --------------------------- | ---------------------------- |

| Accuracy | Low (NAT/VPN false positives) | High | High | Medium (fingerprint changes) |

| Instant forced logout | Not possible | Possible | Delayed until Access expiry | Depends on policy |

| Server state required | Minimal | Redis/DB required | Refresh store required | Device DB required |

| Multi-device control | Difficult | N limit possible | Per-family management | Per-device management |

| Implementation complexity | Easy | Medium | Medium to High | High |

| Privacy risk | Low | Low | Low | High (fingerprint) |

> **Practical recommended combination**: Use **session-based** or **JWT + Refresh Token Rotation** as the main method, with IP and device information as **auxiliary risk signals**.

3. Nginx-Level Check — What It Can and Cannot Do

3-1. What Nginx Does Well

Rate Limiting — Restrict excessive requests from the same IP

limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;

server {

location /api/auth/login {

limit_req zone=login burst=10 nodelay;

proxy_pass http://backend;

}

}

IP-based access control

geo $blocked {

default 0;

10.0.0.0/8 0; # Allow internal network

192.168.0.0/16 0; # Allow internal network

Block specific malicious IPs

203.0.113.50 1;

}

server {

if ($blocked) {

return 403;

}

}

Country-based blocking (GeoIP2 module)

geoip2 /etc/nginx/GeoLite2-Country.mmdb {

$geoip2_country_code country iso_code;

}

map $geoip2_country_code $allowed_country {

KR 1;

US 1;

JP 1;

default 0;

}

3-2. Nginx Limitations

As an **L7 reverse proxy**, Nginx cannot do the following:

- **Interpret session semantics**: Cannot determine "is this request the second session for userId=123"

- **Advanced JWT claim-based logic**: Token parsing is possible, but session validity verification requiring DB/Redis lookups is not

- **Real-time session invalidation**: Immediately terminating a specific user's session is the application layer's responsibility

3-3. njs / Lua / OpenResty Extensions

| Extension | Pros | Cons |

| -------------------------- | ----------------------------------------- | ------------------------------------------------------- |

| **njs** (Nginx JavaScript) | Official Nginx support, lightweight | Limited Redis integration, lack of async IO |

| **Lua (lua-nginx-module)** | Very flexible, direct Redis integration | Requires OpenResty, increased complexity |

| **OpenResty** | Lua + Nginx integration, high performance | Separate build/management, standard Nginx compatibility |

-- OpenResty + Lua example: Extract user_id from JWT and verify Redis session

local jwt = require "resty.jwt"

local redis = require "resty.redis"

local token = ngx.var.http_authorization:sub(8) -- Remove "Bearer "

local jwt_obj = jwt:load_jwt(token)

if jwt_obj.valid then

local red = redis:new()

red:connect("127.0.0.1", 6379)

local active_session = red:get("user:" .. jwt_obj.payload.sub .. ":session")

if active_session ~= jwt_obj.payload.jti then

ngx.status = 401

ngx.say('{"error": "session_expired", "message": "You have been logged in from another location"}')

return ngx.exit(401)

end

end

> **Final recommendation**: **Policy decisions should be made at the application/authentication server**, while Nginx focuses on **first-line defense (rate limiting, IP blocking, basic gating)**. OpenResty/Lua extensions should only be used for cache/lightweight verification in front of the auth server.

4. Implementation Code Examples

4-1. Spring Boot — Concurrent Session Control

Spring Security provides concurrent session control out of the box.

@Configuration

@EnableWebSecurity

public class SecurityConfig {

@Bean

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

http

.sessionManagement(session -> session

.maximumSessions(1) // Maximum 1 session

.maxSessionsPreventsLogin(false) // false: expire existing session (default)

// true: block new login

.expiredSessionStrategy(event -> {

HttpServletResponse response = event.getResponse();

response.setStatus(401);

response.setContentType("application/json;charset=UTF-8");

response.getWriter().write(

"{\"error\":\"session_expired\",\"message\":\"You have been logged in from another location\"}"

);

})

);

return http.build();

}

// Use Spring Session + Redis for distributed environments

@Bean

public SessionRegistry sessionRegistry() {

return new SpringSessionBackedSessionRegistry<>(this.sessionRepository);

}

@Autowired

private FindByIndexNameSessionRepository<?> sessionRepository;

}

**Required dependency for distributed environments**:

4-2. Django — Session Table + Redis Middleware

middleware.py

from django.conf import settings

from django.contrib.auth import logout

r = redis.Redis(host=settings.REDIS_HOST, port=6379, db=0, decode_responses=True)

ACTIVE_SESSION_PREFIX = "user:active_session:"

class SingleSessionMiddleware:

"""Middleware that allows only one session per account"""

def __init__(self, get_response):

self.get_response = get_response

def __call__(self, request):

if request.user.is_authenticated:

key = f"{ACTIVE_SESSION_PREFIX}{request.user.id}"

current_session = request.session.session_key

active_session = r.get(key)

if active_session and active_session != current_session:

Current session is not the active session → Force logout

logout(request)

from django.http import JsonResponse

return JsonResponse(

{"error": "session_expired",

"message": "You have been logged in from another location"},

status=401

)

return self.get_response(request)

signals.py — Register active session on login

from django.contrib.auth.signals import user_logged_in

from django.dispatch import receiver

@receiver(user_logged_in)

def register_active_session(sender, request, user, **kwargs):

key = f"user:active_session:{user.id}"

Delete existing session (from Django session store)

old_session_key = r.get(key)

if old_session_key:

from django.contrib.sessions.models import Session

Session.objects.filter(session_key=old_session_key).delete()

Register new session

r.set(key, request.session.session_key, ex=settings.SESSION_COOKIE_AGE)

4-3. JWT — Refresh Token Rotation Implementation

// Node.js / Express example

const redis = require('ioredis')

const jwt = require('jsonwebtoken')

const crypto = require('crypto')

const client = new redis()

async function login(userId, deviceId) {

const tokenFamily = crypto.randomUUID()

const jti = crypto.randomUUID()

// Revoke existing token family

const oldFamily = await client.get(`user:${userId}:token_family`)

if (oldFamily) {

await client.del(`token_family:${oldFamily}`)

}

// Register new token family

await client.set(`user:${userId}:token_family`, tokenFamily, 'EX', 86400 * 7)

await client.set(

`token_family:${tokenFamily}`,

JSON.stringify({

userId,

deviceId,

jti,

rotationCount: 0,

}),

'EX',

86400 * 7

)

const accessToken = jwt.sign({ sub: userId, jti, device_id: deviceId }, process.env.JWT_SECRET, {

expiresIn: '15m',

})

const refreshToken = jwt.sign(

{ sub: userId, family: tokenFamily, jti: crypto.randomUUID() },

process.env.JWT_REFRESH_SECRET,

{ expiresIn: '7d' }

)

return { accessToken, refreshToken }

}

async function refresh(refreshToken) {

const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)

const familyData = await client.get(`token_family:${decoded.family}`)

if (!familyData) {

// Token family has been revoked → Suspected theft → Invalidate all sessions

await client.del(`user:${decoded.sub}:token_family`)

throw new Error('TOKEN_FAMILY_REVOKED')

}

const family = JSON.parse(familyData)

// Rotation: Issue new tokens + invalidate existing refresh

const newJti = crypto.randomUUID()

family.jti = newJti

family.rotationCount += 1

await client.set(`token_family:${decoded.family}`, JSON.stringify(family), 'EX', 86400 * 7)

const newAccessToken = jwt.sign(

{ sub: decoded.sub, jti: newJti, device_id: family.deviceId },

process.env.JWT_SECRET,

{ expiresIn: '15m' }

)

const newRefreshToken = jwt.sign(

{ sub: decoded.sub, family: decoded.family, jti: crypto.randomUUID() },

process.env.JWT_REFRESH_SECRET,

{ expiresIn: '7d' }

)

return { accessToken: newAccessToken, refreshToken: newRefreshToken }

}

4-4. Nginx Configuration — Rate Limit + Basic Defense

/etc/nginx/conf.d/security.conf

Login endpoint Rate Limit

limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/s;

Global connection limit

limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

Suspicious IP map

map $remote_addr $is_suspicious {

default 0;

~^192\.168\.100\. 1; # Example: Block internal test range

}

server {

listen 443 ssl;

server_name api.example.com;

Login endpoint

location /api/auth/login {

limit_req zone=auth_limit burst=10 nodelay;

limit_conn conn_limit 10;

if ($is_suspicious) {

return 403;

}

proxy_pass http://backend;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

}

Auth-required API — App handles session/token verification

location /api/ {

proxy_pass http://backend;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

}

}

5. Architecture Summary — What to Check Where

┌────────────────────────────────────────────────┐

│ Client │

│ (Browser/App — device_id, fingerprint creation) │

└─────────────────────┬──────────────────────────┘

┌─────────────────────▼──────────────────────────┐

│ Nginx / L7 Proxy │

│ ✅ Rate Limit (login brute-force defense) │

│ ✅ IP blocking (geo, deny) │

│ ✅ Basic header validation │

│ ❌ Session validity judgment (app responsibility)│

└─────────────────────┬──────────────────────────┘

┌─────────────────────▼──────────────────────────┐

│ Auth Server / API Gateway │

│ ✅ JWT verification (signature, expiry, jti blocklist) │

│ ✅ session_version comparison │

│ ✅ Refresh Token Rotation management │

│ ✅ Concurrent session policy decisions │

└─────────────────────┬──────────────────────────┘

┌─────────────────────▼──────────────────────────┐

│ Session/Token Store (Redis) │

│ - user:{id}:sessions → Set of sessionId │

│ - user:{id}:token_family → familyId │

│ - token_family:{id} → {userId, deviceId, jti} │

│ - blacklist:jti:{jti} → TTL │

└──────────────────────────────────────────────────┘

6. Operational Checklist

6-1. Forced Logout UX

- **Clear notification message**: "Your session has been terminated because you logged in from another device"

- **Re-login prompt**: Redirect to login page + preserve previous page URL

- **Real-time notification**: Notify session expiration immediately via WebSocket or SSE (better UX than polling)

6-2. Notifications and Audit Logs

- Send **email/push notifications** on new device login

- Mandatory audit log fields: `timestamp`, `user_id`, `action(login/logout/force_logout)`, `ip`, `user_agent`, `device_id`, `session_id`

- Log retention period aligned with compliance requirements (Financial: 5 years, General: 1-3 years)

6-3. Admin Functions

- Implement ability for admins to **force-clear all sessions** of a specific user

- Integrate with account lock/unlock functionality

- Record admin actions in audit logs as well

6-4. Security and Privacy Considerations

- **Explicit consent** required when collecting device fingerprints (Privacy Protection Act, GDPR)

- IP addresses are considered personal information -- secure legal basis for log retention and processing

- Do not log session IDs/tokens in plaintext (hash them)

- Apply `Secure`, `HttpOnly`, `SameSite` cookie attributes as mandatory

7. Troubleshooting

7-1. False Positive Forced Logout of Legitimate Users

**Problem scenario**: Mobile users get logged out when switching between Wi-Fi and LTE due to IP change

**Response measures**

1. **Remove IP from standalone judgment criteria** -- Use session/token-based verification as primary

2. **Apply Grace Period** -- Do not block immediately on IP change detection; allow 5-10 minute grace period

3. **Prioritize Device ID** -- For apps, use device UUID; for web, prioritize localStorage device_id

4. **User feedback channel** -- "Report if not you" + "This is me" re-authentication button

**Case: "Logged out every time VPN connects/disconnects"**

Cause: IP changes to VPN gateway IP when VPN connects

Solution: Do not terminate session on IP change alone; maintain session if refresh token is valid

+ Only record IP change events in audit log

7-2. Multi-Device Allow Policy Design

Not all services require "single session." **Per-device-type differentiated policies** are more realistic.

**Design pattern: Per-device-type slots**

{

"concurrent_session_policy": {

"max_total": 5,

"per_device_type": {

"web_browser": 2,

"mobile_app": 2,

"tablet_app": 1

},

"on_exceed": "expire_oldest",

"admin_override": true

}

}

**Implementation logic**

Example: Per-device-type slot management

def check_session_limit(user_id, device_type, new_session_id):

key = f"user:{user_id}:sessions:{device_type}"

sessions = redis.lrange(key, 0, -1)

max_slots = POLICY['per_device_type'].get(device_type, 1)

if len(sessions) >= max_slots:

if POLICY['on_exceed'] == 'expire_oldest':

oldest = redis.lpop(key)

invalidate_session(oldest) # Expire oldest session

elif POLICY['on_exceed'] == 'deny_new':

raise SessionLimitExceeded()

redis.rpush(key, new_session_id)

redis.expire(key, SESSION_TTL)

**Policy decision points**

| Decision Item | Options |

| --------------------- | ------------------------------------------------- |

| Action on exceed | Expire existing session vs Block new login |

| Device type detection | User-Agent parsing vs Client explicit declaration |

| Admin exception | Unlimited for admin accounts vs Same policy |

| VIP/plan tiers | Free: 1 device, Pro: 3, Enterprise: unlimited |

8. References

- [Spring Security - Session Management](https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html) — Official session management guide

- [Spring Session + Redis](https://docs.spring.io/spring-session/reference/guides/boot-redis.html) — Distributed session store configuration

- [Django Sessions Documentation](https://docs.djangoproject.com/en/5.0/topics/http/sessions/) — Django sessions official documentation

- [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) — JWT standard specification

- [Auth0 - Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation) — Refresh Token Rotation implementation guide

- [Nginx Rate Limiting](https://www.nginx.com/blog/rate-limiting-nginx/) — Nginx official Rate Limit guide

- [OpenResty Best Practices](https://openresty.org/en/) — OpenResty official site

- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — OWASP session management recommendations

- [njs Scripting Language](https://nginx.org/en/docs/njs/) — Nginx JavaScript module official documentation

Conclusion

Concurrent login prevention seems simple, but **where you check what** greatly affects architectural complexity and user experience.

**Key principles summarized**:

1. **Nginx as first defense** -- Rate limiting and IP blocking to filter out noise.

2. **Policy decisions at app/auth server** -- Session limits, token validation, and forced logout are the application's responsibility.

3. **IP as auxiliary signal** -- Using it as a standalone criterion causes too many false positives.

4. **Server-side state is inevitable** -- Concurrent login control is impossible with completely stateless architecture. At minimum, a refresh token store is needed.

5. **Do not forget UX** -- Without clear guidance and re-login flow on forced logout, security policies lead to user attrition.

Combine the above strategies according to your environment's scale, security requirements, and user patterns.

현재 단락 (1/354)

The need to prevent simultaneous logins from multiple locations with a single account arises more of...

작성 글자: 0원문 글자: 17,772작성 단락: 0/354