필사 모드: Concurrent Login Prevention Implementation Guide — Layer-by-Layer Strategies with IP, Session, JWT, and Nginx Including Practical Code
EnglishIntroduction
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...