Skip to content
Published on

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

Authors

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

ScenarioResult
Corporate NAT environment -- hundreds sharing one public IPMisjudges different users as the same user
Mobile network switching (Wi-Fi to LTE)Legitimate users force-logged out due to IP change
Proxy/VPN usersMultiple 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. LoginCreate session → Redis: user:{userId}:sessions = {sessionId}
2. New login occurs → Look up existing session IDInvalidate 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

TechniqueDescription
jti (JWT ID) based blocklistAdd previous token's jti to blocklist on login. Check blocklist during verification
session_versionsession_version column in user table - increment on login - compare with JWT claim
device_id bindingInclude device-specific ID in JWT, manage allowed device list on server
Refresh Token RotationRevoke existing refresh token family on new login - unable to refresh access token

Refresh Token Rotation Flow

1. LoginIssue 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

CriteriaIP-BasedSession-BasedJWT + Refresh RotationDevice Binding
AccuracyLow (NAT/VPN false positives)HighHighMedium (fingerprint changes)
Instant forced logoutNot possiblePossibleDelayed until Access expiryDepends on policy
Server state requiredMinimalRedis/DB requiredRefresh store requiredDevice DB required
Multi-device controlDifficultN limit possiblePer-family managementPer-device management
Implementation complexityEasyMediumMedium to HighHigh
Privacy riskLowLowLowHigh (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

ExtensionProsCons
njs (Nginx JavaScript)Official Nginx support, lightweightLimited Redis integration, lack of async IO
Lua (lua-nginx-module)Very flexible, direct Redis integrationRequires OpenResty, increased complexity
OpenRestyLua + Nginx integration, high performanceSeparate 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, 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 ItemOptions
Action on exceedExpire existing session vs Block new login
Device type detectionUser-Agent parsing vs Client explicit declaration
Admin exceptionUnlimited for admin accounts vs Same policy
VIP/plan tiersFree: 1 device, Pro: 3, Enterprise: unlimited

8. References


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.