- Published on
Concurrent Login Prevention Implementation Guide — Layer-by-Layer Strategies with IP, Session, JWT, and Nginx Including Practical Code
- Authors

- Name
- Youngju Kim
- @fjvbn20031
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:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
4-2. Django — Session Table + Redis Middleware
# middleware.py
import redis
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,SameSitecookie 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
- Remove IP from standalone judgment criteria -- Use session/token-based verification as primary
- Apply Grace Period -- Do not block immediately on IP change detection; allow 5-10 minute grace period
- Prioritize Device ID -- For apps, use device UUID; for web, prioritize localStorage device_id
- 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 — Official session management guide
- Spring Session + Redis — Distributed session store configuration
- Django Sessions Documentation — Django sessions official documentation
- RFC 7519 - JSON Web Token (JWT) — JWT standard specification
- Auth0 - Refresh Token Rotation — Refresh Token Rotation implementation guide
- Nginx Rate Limiting — Nginx official Rate Limit guide
- OpenResty Best Practices — OpenResty official site
- OWASP Session Management Cheat Sheet — OWASP session management recommendations
- njs Scripting Language — 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:
- Nginx as first defense -- Rate limiting and IP blocking to filter out noise.
- Policy decisions at app/auth server -- Session limits, token validation, and forced logout are the application's responsibility.
- IP as auxiliary signal -- Using it as a standalone criterion causes too many false positives.
- Server-side state is inevitable -- Concurrent login control is impossible with completely stateless architecture. At minimum, a refresh token store is needed.
- 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.