Skip to content

Split View: 동시 로그인 방지 구현 가이드 — IP·세션·JWT·Nginx 계층별 전략과 실전 코드

✨ Learn with Quiz
|

동시 로그인 방지 구현 가이드 — IP·세션·JWT·Nginx 계층별 전략과 실전 코드

들어가며

하나의 계정으로 여러 곳에서 동시에 로그인하는 것을 막아야 하는 상황은 생각보다 자주 등장합니다. 금융 서비스, 유료 스트리밍, B2B SaaS, 사내 어드민 등 "한 계정 = 한 세션" 정책이 필수인 도메인이 많기 때문입니다.

이 글에서는 동시 로그인 방지(Concurrent Session Control) 를 구현하기 위한 네 가지 접근 방식을 아키텍처 관점에서 비교하고, 각 계층에서 무엇을 체크해야 하는지, 그리고 실전 코드와 운영 노하우를 한 편에 정리합니다.


1. 접근 방식 비교

1-1. IP 기반 제한

가장 단순한 방법은 "같은 계정이 서로 다른 IP에서 접속하면 차단"하는 것입니다.

장점

  • 구현이 매우 단순합니다 — 로그인 시 IP를 기록하고, 요청 IP와 비교하면 됩니다.
  • 별도 저장소 없이도 동작할 수 있습니다.

단점 — 오탐(false positive)이 심각합니다

시나리오결과
기업 NAT 환경 — 수백 명이 같은 공인 IP 사용다른 사용자를 같은 사용자로 오판
모바일망 전환 (Wi-Fi → LTE)정상 사용자가 IP 변경으로 강제 로그아웃
프록시·VPN 사용자동일 IP로 다수 사용자가 접속, 또는 한 사용자가 IP가 자주 변동
CGNAT(통신사 공유 IP)전혀 무관한 사용자들이 같은 IP

결론: IP 기반 제한은 보조 지표(risk signal) 로만 사용하고, 단독 정책으로는 부적합합니다. "같은 IP에서 짧은 시간에 여러 계정 로그인 시도" 같은 브루트포스 탐지에는 유효합니다.

1-2. 세션 기반 단일 로그인

서버 세션 저장소(보통 Redis)에서 사용자별 활성 세션을 관리하는 방식입니다.

동작 흐름

1. 로그인 → 세션 생성 → Redis에 user:{userId}:sessions = {sessionId}
2. 새 로그인 발생 → 기존 세션 ID 조회 → 기존 세션 무효화(삭제)
3. 기존 기기에서 요청 → 세션 없음 → 401 + "다른 곳에서 로그인됨" 메시지

장점

  • 서버가 세션 생명주기를 완전히 통제합니다.
  • 즉시 강제 로그아웃이 가능합니다.
  • 최대 동시 세션 수를 N개로 유연하게 설정할 수 있습니다.

단점

  • 서버 사이드 세션 저장소(Redis 등)가 필수입니다.
  • Stateless 아키텍처와 충돌할 수 있습니다.
  • 세션 저장소 장애 시 전체 인증에 영향을 줍니다.

1-3. JWT 기반 단일 로그인

Stateless JWT를 사용하면서 동시 로그인을 제어하려면 서버 측 상태를 최소한으로 유지하는 전략이 필요합니다.

핵심 기법들

기법설명
jti (JWT ID) 기반 블랙리스트로그인 시 이전 토큰의 jti를 블랙리스트에 추가. 검증 시 블랙리스트 확인
session_version사용자 테이블에 session_version 컬럼 → 로그인 시 증가 → JWT 클레임과 비교
device_id 바인딩디바이스별 고유 ID를 JWT에 포함, 서버에서 허용 디바이스 목록 관리
Refresh Token Rotation새 로그인 시 기존 refresh token family 폐기 → access token 갱신 불가

Refresh Token Rotation 흐름

1. 로그인 → access_token + refresh_token 발급
2. refresh_token을 서버 DB/Redis에 저장 (user_id → token_family)
3. 새 기기 로그인 → 새 token_family 발급 + 기존 family 전체 폐기
4. 기존 기기가 refresh 시도 → family가 폐기됨 → 401 + 재로그인 유도

포인트: JWT를 사용하더라도 refresh token의 서버 측 저장은 거의 필수입니다. 완전한 Stateless에서는 동시 로그인 제어가 불가능합니다.

1-4. 디바이스 바인딩 / 위험 기반 인증 (Risk-Based)

User-Agent, IP, 브라우저 핑거프린트 등을 조합하여 디바이스 프로파일을 만들고, 알려지지 않은 디바이스에서의 로그인에 추가 인증을 요구하는 방식입니다.

  • 활용 가능 시그널: User-Agent, IP 대역, 화면 해상도, 타임존, 설치된 폰트, Canvas/WebGL 핑거프린트
  • 주의사항: 브라우저 핑거프린팅은 개인정보 침해 소지가 있으므로, 동의 기반으로만 수집해야 합니다. GDPR, 개인정보보호법 등 법적 요건을 반드시 확인하세요.
  • 권장 패턴: 강제 차단보다는 "새 디바이스에서 로그인 시 이메일/SMS 2차 인증" 형태로 사용합니다.

2. 접근 방식 종합 비교

기준IP 기반세션 기반JWT + Refresh Rotation디바이스 바인딩
정확도❌ 낮음 (NAT/VPN 오탐)✅ 높음✅ 높음⚠️ 중간 (핑거프린트 변동)
즉시 강제 로그아웃❌ 불가✅ 가능⚠️ Access 만료까지 지연⚠️ 정책에 따라 다름
서버 상태 필요최소Redis/DB 필수Refresh 저장소 필요디바이스 DB 필요
멀티 디바이스 제어❌ 어려움✅ N개 제한 가능✅ Family별 관리✅ 디바이스별 관리
구현 난이도쉬움중간중간~높음높음
개인정보 리스크낮음낮음낮음⚠️ 높음 (핑거프린트)

실무 권장 조합: 세션 기반 또는 JWT + Refresh Token Rotation을 메인으로, IP와 디바이스 정보는 보조 risk signal로 활용합니다.


3. Nginx 레벨 체크 — 가능한 것과 한계

3-1. Nginx가 잘하는 것

# Rate Limiting — 동일 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 기반 접근 제어
geo $blocked {
    default 0;
    10.0.0.0/8 0;      # 내부망 허용
    192.168.0.0/16 0;   # 내부망 허용
    # 특정 악성 IP 차단
    203.0.113.50 1;
}

server {
    if ($blocked) {
        return 403;
    }
}
# 국가별 차단 (GeoIP2 모듈)
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의 한계

Nginx는 L7 리버스 프록시이므로 다음을 할 수 없습니다.

  • 사용자 세션의 의미 해석: "이 요청이 userId=123의 두 번째 세션인지" 판단 불가
  • JWT 클레임 기반 고급 로직: 토큰 파싱은 가능하나, DB/Redis 조회 기반의 세션 유효성 검증은 불가
  • 실시간 세션 무효화: 특정 사용자의 세션을 즉시 끊는 동작은 앱 계층의 역할

3-3. njs / Lua / OpenResty 확장

확장 방식장점단점
njs (Nginx JavaScript)Nginx 공식 지원, 가벼움Redis 연동 제한, 비동기 IO 부족
Lua (lua-nginx-module)매우 유연, Redis 직접 연동OpenResty 필요, 복잡도 증가
OpenRestyLua + Nginx 통합, 고성능별도 빌드/관리, 표준 Nginx와 호환성
-- OpenResty + Lua 예시: JWT에서 user_id 추출 후 Redis 세션 검증
local jwt = require "resty.jwt"
local redis = require "resty.redis"

local token = ngx.var.http_authorization:sub(8) -- "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": "다른 곳에서 로그인되었습니다"}')
        return ngx.exit(401)
    end
end

최종 권장: 정책 판단은 애플리케이션/인증 서버에서 수행하고, Nginx는 1차 방어(rate limit, IP 차단, 기본 gating) 역할에 집중합니다. OpenResty/Lua 확장은 인증 서버 앞단의 캐시/경량 검증 용도로만 사용하세요.


4. 구현 예시 코드

4-1. Spring Boot — Concurrent Session Control

Spring Security는 동시 세션 제어를 기본으로 지원합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement(session -> session
                .maximumSessions(1)                    // 최대 1개 세션
                .maxSessionsPreventsLogin(false)        // false: 기존 세션 만료 (기본)
                                                       // true: 새 로그인 차단
                .expiredSessionStrategy(event -> {
                    HttpServletResponse response = event.getResponse();
                    response.setStatus(401);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"error\":\"session_expired\",\"message\":\"다른 곳에서 로그인되었습니다\"}"
                    );
                })
            );
        return http.build();
    }

    // 분산 환경에서는 Spring Session + Redis 사용
    @Bean
    public SessionRegistry sessionRegistry() {
        return new SpringSessionBackedSessionRegistry<>(this.sessionRepository);
    }

    @Autowired
    private FindByIndexNameSessionRepository<?> sessionRepository;
}

분산 환경 필수 의존성:

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

4-2. Django — 세션 테이블 + Redis 미들웨어

# 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:
    """한 계정에 하나의 세션만 허용하는 미들웨어"""

    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:
                # 현재 세션이 활성 세션이 아님 → 강제 로그아웃
                logout(request)
                from django.http import JsonResponse
                return JsonResponse(
                    {"error": "session_expired",
                     "message": "다른 곳에서 로그인되었습니다"},
                    status=401
                )

        return self.get_response(request)
# signals.py — 로그인 시 활성 세션 등록
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}"
    # 기존 세션 삭제 (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()

    # 새 세션 등록
    r.set(key, request.session.session_key, ex=settings.SESSION_COOKIE_AGE)

4-3. JWT — Refresh Token Rotation 구현

// Node.js / Express 예시
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()

  // 기존 token family 폐기
  const oldFamily = await client.get(`user:${userId}:token_family`)
  if (oldFamily) {
    await client.del(`token_family:${oldFamily}`)
  }

  // 새 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가 폐기됨 → 탈취 의심 → 모든 세션 무효화
    await client.del(`user:${decoded.sub}:token_family`)
    throw new Error('TOKEN_FAMILY_REVOKED')
  }

  const family = JSON.parse(familyData)

  // Rotation: 새 토큰 발급 + 기존 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 설정 — Rate Limit + 기본 방어

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

# 로그인 엔드포인트 Rate Limit
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/s;

# 전역 연결 제한
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

# 의심 IP 맵
map $remote_addr $is_suspicious {
    default 0;
    ~^192\.168\.100\. 1;  # 예시: 내부 테스트 대역 차단
}

server {
    listen 443 ssl;
    server_name api.example.com;

    # 로그인 엔드포인트
    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;
    }

    # 인증 필요 API — 앱에서 세션/토큰 검증
    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. 아키텍처 정리 — 무엇을 어디서 체크할 것인가

┌────────────────────────────────────────────────┐
│                  클라이언트                       │
  (브라우저/앱 — device_id, fingerprint 생성)└─────────────────────┬──────────────────────────┘
┌─────────────────────▼──────────────────────────┐
Nginx / L7 Proxy│  ✅ Rate Limit (login brute-force 방어)│  ✅ IP 차단 (geo, deny)│  ✅ 기본 헤더 검증                               │
│  ❌ 세션 유효성 판단 (앱 역할)└─────────────────────┬──────────────────────────┘
┌─────────────────────▼──────────────────────────┐
│           인증 서버 / API Gateway│  ✅ JWT 검증 (서명, 만료, jti 블랙리스트)│  ✅ session_version 비교                         │
│  ✅ Refresh Token Rotation 관리                  │
│  ✅ 동시 세션 수 정책 판단                        │
└─────────────────────┬──────────────────────────┘
┌─────────────────────▼──────────────────────────┐
│              세션/토큰 저장소 (Redis)- user:{id}:sessions → Set<sessionId>- user:{id}:token_family → familyId             │
- token_family:{id}{userId, deviceId, jti}- blacklist:jti:{jti}TTL└──────────────────────────────────────────────────┘

6. 운영 체크리스트

6-1. 강제 로그아웃 UX

  • 명확한 안내 메시지: "다른 기기에서 로그인하여 현재 세션이 종료되었습니다"
  • 재로그인 유도: 로그인 페이지로 리다이렉트 + 이전 페이지 URL 보존
  • 실시간 알림: WebSocket 또는 SSE로 즉시 세션 만료 통지 (polling 대비 UX 우수)

6-2. 알림과 감사 로그

  • 새 디바이스 로그인 시 이메일/푸시 알림 발송
  • 감사 로그 필수 기록 항목: timestamp, user_id, action(login/logout/force_logout), ip, user_agent, device_id, session_id
  • 로그 보존 기간은 컴플라이언스 요건에 맞춤 (금융: 5년, 일반: 1~3년)

6-3. 관리자 기능

  • 관리자가 특정 사용자의 모든 세션 강제 해제 가능하도록 구현
  • 계정 잠금/해제 기능과 연동
  • 관리자 조작도 감사 로그에 기록

6-4. 보안 및 개인정보 고려

  • 디바이스 핑거프린트 수집 시 명시적 동의 필요 (개인정보보호법, GDPR)
  • IP 주소는 개인정보에 해당 — 로그 보관 및 처리 시 법적 근거 확보
  • 세션 ID/토큰을 로그에 평문으로 남기지 않기 (해시 처리)
  • Secure, HttpOnly, SameSite 쿠키 속성 필수 적용

7. 트러블슈팅

7-1. 정상 사용자 오탐으로 강제 로그아웃

문제 상황: 모바일 사용자가 Wi-Fi ↔ LTE 전환 시 IP가 바뀌면서 로그아웃됨

대응 방안

  1. IP를 단독 판단 기준에서 제외 — 세션/토큰 기반 검증을 주력으로 사용
  2. Grace Period 적용 — IP 변경 감지 시 즉시 차단하지 않고, 5~10분 유예 기간 부여
  3. 디바이스 ID 우선 — 앱이라면 디바이스 UUID, 웹이라면 localStorage의 device_id를 우선 참조
  4. 사용자 피드백 채널 — "본인이 아닌 경우 신고" + "본인입니다" 재인증 버튼

사례: "회사 VPN 연결/해제 시 매번 로그아웃"

원인: VPN 연결 시 IPVPN 게이트웨이 IP로 변경
해결: IP 변경만으로는 세션을 끊지 않고, refresh token이 유효하면 세션 유지
      + IP 변경 이벤트를 감사 로그에만 기록

7-2. 멀티 디바이스 허용 정책 설계

모든 서비스가 "단일 세션"을 요구하지는 않습니다. 디바이스 유형별 차등 정책이 더 현실적입니다.

설계 패턴: 디바이스 유형별 슬롯

{
  "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
  }
}

구현 로직

# 예시: 디바이스 유형별 슬롯 관리
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)  # 가장 오래된 세션 만료
        elif POLICY['on_exceed'] == 'deny_new':
            raise SessionLimitExceeded()

    redis.rpush(key, new_session_id)
    redis.expire(key, SESSION_TTL)

정책 결정 포인트

결정 항목선택지
초과 시 동작기존 세션 만료 vs 새 로그인 차단
디바이스 유형 구분User-Agent 파싱 vs 클라이언트 명시 전달
관리자 예외관리자 계정은 무제한 vs 동일 정책
VIP/플랜별 차등Free: 1대, Pro: 3대, Enterprise: 무제한

8. 참고 자료


마무리

동시 로그인 방지는 단순해 보이지만, 어디서 무엇을 체크하느냐에 따라 아키텍처 복잡도와 사용자 경험이 크게 달라집니다.

핵심 원칙을 정리하면:

  1. Nginx는 1차 방어 — Rate limit, IP 차단으로 노이즈를 걸러냅니다.
  2. 정책 판단은 앱/인증 서버 — 세션 수 제한, 토큰 유효성 검증, 강제 로그아웃은 애플리케이션의 역할입니다.
  3. IP는 보조 지표 — 단독 판단 기준으로 사용하면 오탐이 많습니다.
  4. 서버 측 상태는 불가피 — 완전한 Stateless에서는 동시 로그인 제어가 불가능합니다. 최소한 refresh token 저장소는 필요합니다.
  5. UX를 잊지 마세요 — 강제 로그아웃 시 명확한 안내와 재로그인 동선이 없으면, 보안 정책이 사용자 이탈로 이어집니다.

운영 환경의 규모, 보안 요구사항, 사용자 패턴에 맞게 위 전략들을 조합하여 적용하시기 바랍니다.

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

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.