Skip to content

Split View: SSO 쿠키/JWT 인증 시스템 완전 가이드 — 프레임워크별 실전 시리즈 인덱스

|

SSO 쿠키/JWT 인증 시스템 완전 가이드 — 프레임워크별 실전 시리즈 인덱스

시리즈 소개 — 왜 이 시리즈를 쓰는가

웹 인증은 단순하지 않습니다. "로그인 기능 하나 만들면 되지"라고 생각하기 쉽지만, 실전에서는 다음과 같은 질문이 꼬리를 물고 나타납니다.

  • 토큰을 쿠키에 넣을까, localStorage에 넣을까?
  • JWT세션 중 무엇을 선택해야 할까?
  • HttpOnly CookieCSRF 토큰을 어떻게 조합할까?
  • SSO(Single Sign-On) 환경에서 여러 서비스 간 인증 상태를 어떻게 공유할까?
  • Access Token 갱신은 프론트엔드에서 하나, 백엔드에서 하나?
  • CORScredentials 설정은 왜 항상 삽질을 유발할까?

이 질문들의 정답은 프레임워크마다 다릅니다. Spring Boot의 SecurityFilterChain과 Django의 SessionMiddleware는 기본 전략부터 다르고, React SPA와 Next.js SSR은 쿠키를 다루는 방식 자체가 다릅니다. 공식 문서만으로는 전체 그림을 그리기 어렵습니다.

이 시리즈는 인증 시스템의 전체 지도를 먼저 펼친 뒤, 프레임워크별로 실전 코드를 구현하는 구성입니다.

시리즈가 다루는 범위:

  • SSO / OAuth 2.0 / OIDC 프로토콜의 동작 원리
  • 쿠키 기반 인증과 JWT 기반 인증의 차이, 장단점, 하이브리드 전략
  • 브라우저 저장소(Cookie, localStorage, sessionStorage, Memory)별 보안 특성
  • CORS + Credential 전송의 정확한 설정법
  • Spring Boot, Django, React, Next.js 각 프레임워크에서의 실전 구현
  • 여러 프레임워크를 조합한 하이브리드 아키텍처 설계

인증 흐름 전체 지도

인증 시스템의 전체 흐름을 한눈에 파악하겠습니다. 아래는 SSO/OIDC 기반 로그인부터 토큰 갱신, 로그아웃까지의 전체 과정입니다.

로그인 흐름 (Login Flow)

┌──────────┐     ┌──────────────┐     ┌──────────────┐     ┌──────────┐
Browser  │     │  Frontend    │     │  Backend     │     │   IdP (사용자) (React/Next) (Spring/ (Keycloak│
│          │     │              │     │  Django)     │     │  Okta)└────┬─────┘     └──────┬───────┘     └──────┬───────┘     └────┬─────┘
1. 로그인 클릭   │                    │                  │
     │─────────────────▶│                    │                  │
     │                  │  2. /auth/login    │                  │
     │                  │───────────────────▶│                  │
     │                  │                    │  3. Redirect URL     │                  │                    │─────────────────▶│
4. IdP 로그인 페이지                  │                  │
     │◀──────────────────────────────────────────────────────────│
5. 사용자 인증 (ID/PW, MFA)           │                  │
     │──────────────────────────────────────────────────────────▶│
     │                  │                    │  6. Auth Code     │                  │                    │◀─────────────────│
     │                  │  7. CodeToken   │                  │
     │                  │◀───────────────────│                  │
     │                  │                      (id_token +     │                  │                    │   access_token +     │                  │                    │   refresh_token)8. Set-Cookie:  │                    │                  │
     │     access_token │                    │                  │
          (HttpOnly)   │                    │                  │
     │◀─────────────────│                    │                  │
9. 인증 완료,    │                    │                  │
     │     대시보드 이동  │                    │                  │
     │◀─────────────────│                    │                  │

인증된 요청 흐름 (Authenticated Request Flow)

┌──────────┐     ┌──────────────┐     ┌──────────────┐
Browser  │     │  Frontend    │     │  Backend└────┬─────┘     └──────┬───────┘     └──────┬───────┘
API 요청         │                    │
     │─────────────────▶│                    │
     │                  │  Cookie 자동 전송   │
  (access_token)     │                  │───────────────────▶│
     │                  │                    │  JWT 검증
     │                  │                      (서명, 만료, 클레임)
     │                  │   200 + 데이터     │
     │                  │◀───────────────────│
     │  렌더링           │                    │
     │◀─────────────────│                    │

토큰 갱신 흐름 (Token Refresh Flow)

┌──────────┐     ┌──────────────┐     ┌──────────────┐
Browser  │     │  Frontend    │     │  Backend└────┬─────┘     └──────┬───────┘     └──────┬───────┘
API 요청         │                    │
     │─────────────────▶│                    │
     │                  │  Cookie 전송        │
     │                  │───────────────────▶│
     │                  │   401 Unauthorized     │                  │◀───────────────────│
     │                  │                    │
     │                  │  POST /auth/refresh│
  (refresh_token    │
     │                  │   Cookie 전송)     │                  │───────────────────▶│
     │                  │   새 access_token  │
     │                  │   Set-Cookie     │                  │◀───────────────────│
     │                  │                    │
     │                  │  원래 요청 재시도    │
     │                  │───────────────────▶│
     │                  │   200 + 데이터     │
     │                  │◀───────────────────│
     │  렌더링           │                    │
     │◀─────────────────│                    │

로그아웃 흐름 (Logout Flow)

┌──────────┐     ┌──────────────┐     ┌──────────────┐     ┌──────────┐
Browser  │     │  Frontend    │     │  Backend     │     │   IdP└────┬─────┘     └──────┬───────┘     └──────┬───────┘     └────┬─────┘
     │  로그아웃 클릭     │                    │                  │
     │─────────────────▶│                    │                  │
     │                  │  POST /auth/logout │                  │
     │                  │───────────────────▶│                  │
     │                  │                    │  Revoke Token     │                  │                    │─────────────────▶│
     │                  │   Set-Cookie:      │                  │
     │                  │   access_token=""  │                  │
     │                  │   Max-Age=0        │                  │
     │                  │◀───────────────────│                  │
     │  쿠키 삭제,       │                    │                  │
     │  로그인 페이지     │                    │                  │
     │◀─────────────────│                    │                  │

핵심 포인트: 백엔드가 HttpOnly Cookie로 토큰을 관리하면 프론트엔드 JavaScript는 토큰에 직접 접근할 수 없습니다. 이것이 XSS 공격을 방어하는 가장 효과적인 전략입니다.


브라우저 저장소별 접근 가능성

토큰을 어디에 저장할지는 보안과 편의성의 트레이드오프입니다. 각 저장소의 특성을 정확히 이해해야 올바른 선택을 할 수 있습니다.

저장소JS 접근서버 자동 전송XSS 취약CSRF 취약
HttpOnly Cookie
Non-HttpOnly Cookie
localStorage
sessionStorage
Memory (JS 변수)

각 저장소 심층 분석:

  • HttpOnly Cookie: JavaScript에서 접근 불가능하므로 XSS에 안전합니다. 단, 브라우저가 모든 요청에 자동으로 쿠키를 첨부하므로 CSRF 공격에 취약합니다. SameSite 속성CSRF 토큰을 반드시 병행해야 합니다.
  • Non-HttpOnly Cookie: document.cookie로 접근 가능하므로 XSS와 CSRF 모두에 취약합니다. 가능하면 사용하지 마세요.
  • localStorage: 탭을 닫아도 데이터가 유지됩니다. 서버에 자동 전송되지 않아 CSRF에는 안전하지만, XSS 공격으로 토큰이 탈취될 수 있습니다.
  • sessionStorage: 탭 단위로 격리되고 탭을 닫으면 삭제됩니다. 보안 특성은 localStorage와 동일합니다.
  • Memory (JS 변수): 페이지 새로고침 시 소멸합니다. XSS에 완전히 안전하지는 않지만(실행 중인 스크립트가 접근 가능), 공격 난이도가 높습니다. Silent Refresh와 조합하면 좋은 전략입니다.

실전 권장: Access Token은 HttpOnly Cookie, CSRF 방어는 SameSite=Lax + CSRF Token 이중 적용. Refresh Token도 HttpOnly Cookie에 저장하되 Path를 /auth/refresh로 제한하세요.


쿠키 보안 속성 빠른 참조

쿠키는 단순한 key=value가 아닙니다. 보안 속성 하나를 빠뜨리면 인증 시스템 전체가 무너질 수 있습니다.

속성별 상세 설명

속성설명
HttpOnlytrueJavaScript에서 접근 차단. XSS 방어의 핵심
SecuretrueHTTPS 연결에서만 쿠키 전송
SameSiteStrict모든 크로스사이트 요청에서 쿠키 미전송. 가장 안전하지만 외부 링크 클릭 시 로그인 풀림
SameSiteLaxGET 네비게이션은 허용, POST 등은 차단. 대부분의 경우 권장
SameSiteNone크로스사이트 요청에서도 쿠키 전송. 반드시 Secure와 함께 사용. SSO에서 필수
Domain.example.com서브도메인 간 쿠키 공유. SSO에서 중요
Path/auth해당 경로 하위에서만 쿠키 전송
Max-Age3600초 단위 만료 시간. 0이면 즉시 삭제
Expires날짜 문자열절대 만료 시각. Max-Age가 우선

실전 설정 예시

Set-Cookie: access_token=eyJhbGciOi...;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900;
  Domain=.myservice.com

Set-Cookie: refresh_token=dGhpcyBpcyBh...;
  HttpOnly;
  Secure;
  SameSite=Strict;
  Path=/api/auth/refresh;
  Max-Age=604800;
  Domain=.myservice.com

Set-Cookie: csrf_token=abc123def456;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900;
  Domain=.myservice.com

주의: csrf_tokenHttpOnly가 아닙니다. 프론트엔드가 이 값을 읽어서 요청 헤더(X-CSRF-Token)에 포함해야 하므로 JavaScript 접근이 필요합니다.

SameSite 전략 결정 트리:

SSO(크로스 도메인) 필요?
├── YesSameSite=None; Secure (+ CSRF Token 필수)
└── No
    ├── 외부 링크에서 유입 시 로그인 유지 필요?
    │   ├── YesSameSite=Lax
    │   └── NoSameSite=Strict
    └── API 전용 쿠키?
        └── SameSite=Strict (가장 안전)

JWT 구조와 핵심 클레임

JWT(JSON Web Token)는 .으로 구분된 세 부분으로 구성됩니다.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.     Header (Base64URL)
eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiQU...Payload (Base64URL)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ...Signature
{
  "alg": "RS256", // 서명 알고리즘 (RS256, HS256, ES256 등)
  "typ": "JWT", // 토큰 타입
  "kid": "key-id-001" // Key ID (JWK Set에서 공개키 식별용)
}

Payload (Claims)

{
  "sub": "user123", // Subject — 사용자 고유 식별자
  "iss": "https://idp.myservice.com", // Issuer — 토큰 발급자
  "aud": "https://api.myservice.com", // Audience — 토큰 수신 대상
  "exp": 1741420800, // Expiration — 만료 시각 (Unix timestamp)
  "iat": 1741417200, // Issued At — 발급 시각
  "nbf": 1741417200, // Not Before — 이 시각 이전에는 무효
  "jti": "unique-token-id-789", // JWT ID — 토큰 고유 ID (재사용 방지)
  "roles": ["ADMIN", "USER"], // Custom — 사용자 권한
  "tenant_id": "tenant-abc", // Custom — 멀티테넌트 식별
  "email": "user@example.com" // Custom — 사용자 이메일
}

Signature 검증

RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  publicKey
)

JWT 파싱 예시 (의사 코드)

import base64, json

def parse_jwt(token):
    header_b64, payload_b64, signature = token.split(".")

    # Base64URL 디코딩 (패딩 보정)
    header = json.loads(
        base64.urlsafe_b64decode(header_b64 + "==")
    )
    payload = json.loads(
        base64.urlsafe_b64decode(payload_b64 + "==")
    )

    # 만료 검증
    import time
    if payload["exp"] < time.time():
        raise Exception("토큰이 만료되었습니다")

    # 발급자 검증
    if payload["iss"] != "https://idp.myservice.com":
        raise Exception("신뢰할 수 없는 발급자입니다")

    return header, payload

주의: 실제 운영 환경에서는 반드시 서명을 검증해야 합니다. 위 코드는 구조 이해를 위한 예시이며, PyJWT, jose, jsonwebtoken 등 검증된 라이브러리를 사용하세요.

JWT vs 세션(Session) 비교:

항목JWT (Stateless)세션 (Stateful)
서버 저장소불필요필요 (Redis, DB)
수평 확장용이세션 스토어 공유 필요
즉시 무효화어려움 (Blocklist 필요)쉬움 (세션 삭제)
토큰 크기큼 (클레임 포함)작음 (세션 ID만)
적합 시나리오MSA, API Gateway모놀리식, 단일 서버

CORS와 Credential 전송

쿠키 기반 인증에서 가장 많은 개발자가 삽질하는 구간이 CORS(Cross-Origin Resource Sharing) 설정입니다. 프론트엔드와 백엔드의 도메인이 다를 때 쿠키 전송이 차단되는 문제는 거의 모든 프로젝트에서 한 번쯤 겪습니다.

올바른 설정

프론트엔드 (fetch API):

// credentials: 'include'가 핵심
const response = await fetch('https://api.myservice.com/users/me', {
  method: 'GET',
  credentials: 'include', // 쿠키를 포함하여 전송
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': getCsrfToken(), // CSRF 토큰 헤더
  },
})

프론트엔드 (Axios):

const api = axios.create({
  baseURL: 'https://api.myservice.com',
  withCredentials: true, // 쿠키를 포함하여 전송
})

// 인터셉터로 CSRF 토큰 자동 첨부
api.interceptors.request.use((config) => {
  const csrfToken = getCookieValue('csrf_token')
  if (csrfToken) {
    config.headers['X-CSRF-Token'] = csrfToken
  }
  return config
})

백엔드 응답 헤더:

Access-Control-Allow-Origin: https://app.myservice.com   ← 정확한 Origin
Access-Control-Allow-Credentials: true                    ← 필수
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token ← 커스텀 헤더 허용
Access-Control-Allow-Methods: GET, POST, PUT, DELETE      ← 허용 메서드
Access-Control-Expose-Headers: X-Request-Id               ← 프론트에서 읽을 헤더

흔한 실수와 해결법

실수 1: Access-Control-Allow-Origin: *credentials 동시 사용

# 이렇게 하면 브라우저가 차단합니다
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# → 에러: credentials 모드에서는 와일드카드 Origin 불가

해결: Origin을 정확하게 지정하세요. 여러 Origin을 지원해야 한다면 요청의 Origin 헤더를 확인하고 화이트리스트에 있는 경우 동적으로 설정합니다.

# Django 예시
ALLOWED_ORIGINS = [
    'https://app.myservice.com',
    'https://admin.myservice.com',
]

# django-cors-headers 설정
CORS_ALLOWED_ORIGINS = ALLOWED_ORIGINS
CORS_ALLOW_CREDENTIALS = True

실수 2: Preflight 요청(OPTIONS)에서 쿠키 전송 기대

# Preflight(OPTIONS) 요청에는 쿠키가 포함되지 않습니다!
# OPTIONS 응답에서 인증을 요구하면 안 됩니다.
# → Backend의 인증 필터에서 OPTIONS 메서드를 제외하세요.

실수 3: SameSite=Strict 쿠키가 크로스오리진 요청에서 전송되지 않음

# 프론트: https://app.myservice.com
# 백엔드: https://api.myservice.com
# → 서로 다른 Origin이므로 SameSite=Strict 쿠키는 전송되지 않습니다
# → SameSite=None; Secure 또는 같은 상위 도메인 사용

실수 4: 로컬 개발 환경에서 localhost vs 127.0.0.1

# localhost:3000 → localhost:8080 : 같은 사이트 (쿠키 전송됨)
# localhost:3000127.0.0.1:8080 : 다른 사이트! (쿠키 전송 안됨)
# → 개발 환경에서도 동일한 호스트명을 사용하세요

시리즈 목차

이 시리즈는 총 5편으로 구성되어 있습니다. 이 인덱스 페이지에서 공통 개념을 익힌 후, 각 프레임워크별 실전 구현으로 이동하세요.

제목핵심 내용
0편현재 글 (인덱스)인증 흐름 전체 지도, 공통 개념 정리
1편Spring BootSecurityFilterChain, JWT 필터, CORS 설정, CSRF 방어
2편DjangoDRF + SimpleJWT, SessionMiddleware, django-cors-headers
3편ReactAxios 인터셉터, Silent Refresh, Protected Route
4편Next.jsMiddleware, Server Actions, SSR 쿠키 전달, next-auth
5편통합 실전편 — SSO/OIDC + 하이브리드 아키텍처Keycloak/Okta 연동, BFF 패턴, 멀티 서비스 SSO

읽는 순서 권장:

  1. 이 인덱스 글에서 전체 흐름을 파악합니다.
  2. 자신이 사용하는 백엔드 프레임워크 편(1편 또는 2편)을 읽습니다.
  3. 프론트엔드 편(3편 또는 4편)을 읽습니다.
  4. 5편에서 전체를 통합하는 아키텍처를 학습합니다.

보안 체크리스트 (시리즈 공통)

프레임워크에 관계없이 모든 인증 시스템에 적용해야 하는 보안 항목입니다. 배포 전 반드시 확인하세요.

쿠키 설정

  • Access Token 쿠키에 HttpOnly 속성이 설정되어 있는가?
  • 모든 인증 쿠키에 Secure 속성이 설정되어 있는가?
  • SameSite 속성이 서비스 아키텍처에 맞게 설정되어 있는가?
  • Refresh Token 쿠키의 Path가 갱신 엔드포인트로 제한되어 있는가?
  • 쿠키의 Max-Age가 적절한 시간으로 설정되어 있는가?
  • 쿠키의 Domain이 필요한 범위로만 제한되어 있는가?

토큰 관리

  • Access Token의 만료 시간이 충분히 짧은가? (권장: 5~15분)
  • Refresh Token의 만료 시간이 설정되어 있는가? (권장: 7~30일)
  • Refresh Token Rotation이 구현되어 있는가?
  • 탈취된 Refresh Token을 무효화할 수 있는 메커니즘이 있는가?
  • JWT 서명에 RS256 또는 ES256 비대칭 알고리즘을 사용하고 있는가?
  • JWT의 iss, aud, exp 클레임을 모두 검증하고 있는가?

CSRF 방어

  • 상태 변경 요청(POST, PUT, DELETE)에 CSRF 토큰을 검증하고 있는가?
  • CSRF 토큰이 요청마다 또는 세션마다 갱신되는가?
  • SameSite 쿠키 속성과 CSRF 토큰을 이중으로 적용했는가?

CORS 설정

  • Access-Control-Allow-Origin에 와일드카드(*)를 사용하지 않는가?
  • Access-Control-Allow-Credentials: true가 설정되어 있는가?
  • 허용할 Origin 목록이 화이트리스트로 관리되고 있는가?
  • Preflight 요청(OPTIONS)이 인증 없이 통과하도록 설정되어 있는가?

전송 보안

  • 모든 통신이 HTTPS를 통해 이루어지는가?
  • HSTS(HTTP Strict Transport Security) 헤더가 설정되어 있는가?
  • TLS 1.2 이상을 사용하고 있는가?

로그아웃

  • 로그아웃 시 서버 측 세션/토큰이 무효화되는가?
  • 로그아웃 시 모든 인증 쿠키가 삭제되는가?
  • SSO 환경에서 Single Logout(SLO)이 구현되어 있는가?

흔한 버그와 오해

오해 1: "JWT는 암호화되어 있다"

JWT는 서명(Signed)되어 있을 뿐 암호화(Encrypted)되어 있지 않습니다. Base64URL 인코딩은 암호화가 아닙니다. 누구나 Payload를 디코딩하여 내용을 읽을 수 있습니다.

# JWT Payload 디코딩 (누구나 가능)
echo "eyJzdWIiOiJ1c2VyMTIzIn0" | base64 -d
# 출력: {"sub":"user123"}

민감한 정보(비밀번호, 주민번호 등)를 JWT Payload에 절대 넣지 마세요. 정말 암호화가 필요하다면 JWE(JSON Web Encryption)를 사용하세요.

오해 2: "localStorage에 JWT를 저장해도 괜찮다"

SPA 프레임워크의 많은 튜토리얼이 localStorage에 JWT를 저장하는 예제를 보여줍니다. 이는 XSS 공격에 취약합니다. 서드파티 라이브러리 하나의 취약점만으로 토큰이 탈취될 수 있습니다.

// 위험한 패턴
localStorage.setItem('token', jwt)

// 안전한 패턴: HttpOnly Cookie 사용 (서버에서 설정)
// 프론트엔드는 토큰을 직접 다루지 않음

오해 3: "SameSite=Lax이면 CSRF 공격이 완전히 방어된다"

SameSite=Lax는 대부분의 CSRF를 방어하지만 완전하지 않습니다. 크로스사이트 GET 요청에는 쿠키가 전송됩니다. 만약 GET 요청으로 상태를 변경하는 API가 있다면 여전히 취약합니다.

# GET /api/users/delete?id=123  ← 이런 APISameSite=Lax로도 방어 불가!
# → 상태 변경은 반드시 POST/PUT/DELETE로 설계하세요

오해 4: "Refresh Token은 Access Token보다 안전하다"

Refresh Token은 장기간 유효하므로 탈취되면 Access Token보다 더 위험합니다. Refresh Token이 탈취되면 공격자가 무한정 새로운 Access Token을 발급받을 수 있습니다.

방어 전략:

  • Refresh Token Rotation: 갱신 시 이전 토큰 즉시 무효화
  • Token Family 추적: 이미 사용된 Refresh Token이 다시 사용되면 해당 Family 전체 무효화
  • Refresh Token을 HttpOnly Cookie에 저장하고 Path 제한

오해 5: "CORS가 서버를 보호해준다"

CORS는 브라우저의 정책입니다. curl, Postman, 서버 간 통신에서는 CORS가 적용되지 않습니다. CORS는 악성 웹사이트가 사용자의 브라우저를 이용해 API를 호출하는 것을 방지할 뿐, API 자체를 보호하지 않습니다.

# CORS와 무관하게 동작
curl -X POST https://api.myservice.com/data \
  -H "Cookie: access_token=stolen_token"
# → 서버 측 토큰 검증이 반드시 필요합니다

흔한 버그: 토큰 갱신 레이스 컨디션

여러 API 요청이 동시에 401을 받으면 Refresh 요청도 여러 번 발생합니다. Refresh Token Rotation을 사용하는 경우 첫 번째 요청만 성공하고 나머지는 실패합니다.

// 해결: 갱신 요청을 하나로 모으는 패턴
let refreshPromise = null

async function refreshToken() {
  if (refreshPromise) return refreshPromise // 이미 갱신 중이면 대기

  refreshPromise = axios.post('/auth/refresh').finally(() => {
    refreshPromise = null
  })

  return refreshPromise
}

참고자료

이 시리즈를 작성하면서 참고한 공식 문서와 표준 명세입니다. 인증 시스템을 설계할 때 반드시 원문을 확인하세요.

표준 명세 (RFC)

  1. RFC 7519 — JSON Web Token (JWT) — JWT 표준 명세. 클레임, 서명, 검증 절차 정의
  2. RFC 6749 — OAuth 2.0 Authorization Framework — OAuth 2.0 핵심 프레임워크. Grant Type별 흐름 정의
  3. RFC 6750 — OAuth 2.0 Bearer Token Usage — Bearer Token 전송 방법 (Authorization 헤더, 쿼리 파라미터 등)
  4. RFC 7517 — JSON Web Key (JWK) — 공개키 배포를 위한 JWK Set 표준
  5. RFC 6265 — HTTP State Management Mechanism (Cookie) — HTTP 쿠키 표준 명세

OpenID Connect

  1. OpenID Connect Core 1.0 — OAuth 2.0 위에 인증(Authentication) 레이어를 추가한 표준

보안 가이드라인

  1. OWASP — Session Management Cheat Sheet — 세션 관리 보안 모범 사례
  2. OWASP — JSON Web Token Cheat Sheet — JWT 사용 시 보안 체크리스트
  3. OWASP — Cross-Site Request Forgery Prevention — CSRF 방어 전략

프레임워크 공식 문서

  1. MDN — HTTP Cookies — 쿠키 속성(SameSite, HttpOnly, Secure 등) 상세 설명
  2. Spring Security — OAuth 2.0 Resource Server — Spring Boot JWT 인증 공식 가이드
  3. Django REST Framework — Authentication — DRF 인증 메커니즘 공식 문서
  4. Next.js — Authentication — Next.js App Router 기반 인증 구현 가이드
  5. MDN — CORS — Cross-Origin Resource Sharing 완전 가이드

SSO Cookie/JWT Authentication System Complete Guide — Framework-Specific Practical Series Index

Series Introduction — Why This Series

Web authentication is not simple. It is easy to think "just build a login feature," but in practice, questions keep piling up:

  • Should I put tokens in cookies or localStorage?
  • Which should I choose between JWT and sessions?
  • How should I combine HttpOnly Cookies and CSRF tokens?
  • How do I share authentication state across multiple services in an SSO (Single Sign-On) environment?
  • Should Access Token refresh happen on the frontend or backend?
  • Why does CORS and credentials setup always cause headaches?

The answers to these questions differ by framework. Spring Boot's SecurityFilterChain and Django's SessionMiddleware have different default strategies, and React SPA and Next.js SSR handle cookies in fundamentally different ways. Official documentation alone is not enough to see the full picture.

This series first lays out the complete map of the authentication system, then implements practical code for each framework.

What the series covers:

  • How SSO / OAuth 2.0 / OIDC protocols work
  • Differences, pros/cons, and hybrid strategies for cookie-based vs JWT-based authentication
  • Security characteristics of each browser storage type (Cookie, localStorage, sessionStorage, Memory)
  • Exact configuration for CORS + Credential transmission
  • Practical implementation for each framework: Spring Boot, Django, React, Next.js
  • Hybrid architecture design combining multiple frameworks

Complete Authentication Flow Map

Let us grasp the complete flow of the authentication system at a glance. Below is the entire process from SSO/OIDC-based login through token refresh to logout.

Login Flow

┌──────────┐     ┌──────────────┐     ┌──────────────┐     ┌──────────┐
Browser  │     │  Frontend    │     │  Backend     │     │   IdP (User) (React/Next) (Spring/ (Keycloak│
│          │     │              │     │  Django)     │     │  Okta)└────┬─────┘     └──────┬───────┘     └──────┬───────┘     └────┬─────┘
1. Click login  │                    │                  │
     │─────────────────▶│                    │                  │
     │                  │  2. /auth/login    │                  │
     │                  │───────────────────▶│                  │
     │                  │                    │  3. Redirect URL     │                  │                    │─────────────────▶│
4. IdP login page                    │                  │
     │◀──────────────────────────────────────────────────────────│
5. User authentication (ID/PW, MFA)  │                  │
     │──────────────────────────────────────────────────────────▶│
     │                  │                    │  6. Auth Code     │                  │                    │◀─────────────────│
     │                  │  7. CodeToken   │                  │
     │                  │◀───────────────────│                  │
     │                  │                      (id_token +     │                  │                    │   access_token +     │                  │                    │   refresh_token)8. Set-Cookie:  │                    │                  │
     │     access_token │                    │                  │
          (HttpOnly)   │                    │                  │
     │◀─────────────────│                    │                  │
9. Auth complete,│                    │                  │
     │     go to dashboard│                   │                  │
     │◀─────────────────│                    │                  │

Authenticated Request Flow

┌──────────┐     ┌──────────────┐     ┌──────────────┐
Browser  │     │  Frontend    │     │  Backend└────┬─────┘     └──────┬───────┘     └──────┬───────┘
API request      │                    │
     │─────────────────▶│                    │
     │                  │  Cookie auto-sent   │
  (access_token)     │                  │───────────────────▶│
     │                  │                    │  JWT verification
     │                  │                      (signature, expiry, claims)
     │                  │   200 + data       │
     │                  │◀───────────────────│
Render           │                    │
     │◀─────────────────│                    │

Token Refresh Flow

┌──────────┐     ┌──────────────┐     ┌──────────────┐
Browser  │     │  Frontend    │     │  Backend└────┬─────┘     └──────┬───────┘     └──────┬───────┘
API request      │                    │
     │─────────────────▶│                    │
     │                  │  Cookie sent        │
     │                  │───────────────────▶│
     │                  │   401 Unauthorized     │                  │◀───────────────────│
     │                  │                    │
     │                  │  POST /auth/refresh│
  (refresh_token    │
     │                  │   Cookie sent)     │                  │───────────────────▶│
     │                  │   New access_token │
     │                  │   Set-Cookie     │                  │◀───────────────────│
     │                  │                    │
     │                  │  Retry original    │
     │                  │  request           │
     │                  │───────────────────▶│
     │                  │   200 + data       │
     │                  │◀───────────────────│
Render           │                    │
     │◀─────────────────│                    │

Logout Flow

┌──────────┐     ┌──────────────┐     ┌──────────────┐     ┌──────────┐
Browser  │     │  Frontend    │     │  Backend     │     │   IdP└────┬─────┘     └──────┬───────┘     └──────┬───────┘     └────┬─────┘
Click logout     │                    │                  │
     │─────────────────▶│                    │                  │
     │                  │  POST /auth/logout │                  │
     │                  │───────────────────▶│                  │
     │                  │                    │  Revoke Token     │                  │                    │─────────────────▶│
     │                  │   Set-Cookie:      │                  │
     │                  │   access_token=""  │                  │
     │                  │   Max-Age=0        │                  │
     │                  │◀───────────────────│                  │
Cookie deleted,  │                    │                  │
     │  login page       │                    │                  │
     │◀─────────────────│                    │                  │

Key point: When the backend manages tokens with HttpOnly Cookies, frontend JavaScript cannot directly access the tokens. This is the most effective strategy for defending against XSS attacks.


Browser Storage Access Characteristics

Where to store tokens is a trade-off between security and convenience. You must accurately understand each storage type to make the right choice.

StorageJS AccessAuto-sent to ServerXSS VulnerableCSRF Vulnerable
HttpOnly CookieNoYesNoYes
Non-HttpOnly CookieYesYesYesYes
localStorageYesNoYesNo
sessionStorageYesNoYesNo
Memory (JS variable)YesNoPartialNo

In-depth analysis of each storage:

  • HttpOnly Cookie: Cannot be accessed from JavaScript, making it safe from XSS. However, since the browser automatically attaches cookies to every request, it is vulnerable to CSRF attacks. You must always combine it with the SameSite attribute and CSRF tokens.
  • Non-HttpOnly Cookie: Accessible via document.cookie, making it vulnerable to both XSS and CSRF. Avoid using this when possible.
  • localStorage: Data persists even after closing the tab. Safe from CSRF since it is not automatically sent to the server, but tokens can be stolen through XSS attacks.
  • sessionStorage: Isolated per tab and deleted when the tab closes. Security characteristics are the same as localStorage.
  • Memory (JS variable): Destroyed on page refresh. Not completely safe from XSS (running scripts can access it), but the attack difficulty is higher. Works well when combined with Silent Refresh.

Practical recommendation: Access Token in HttpOnly Cookie, CSRF defense with dual SameSite=Lax + CSRF Token. Store Refresh Token in HttpOnly Cookie as well, but restrict the Path to /auth/refresh.


Cookies are not just simple key=value pairs. Missing a single security attribute can bring down the entire authentication system.

Detailed Attribute Descriptions

AttributeValueDescription
HttpOnlytrueBlocks JavaScript access. Core of XSS defense
SecuretrueCookie sent only over HTTPS connections
SameSiteStrictCookie not sent on any cross-site request. Most secure but login drops on external links
SameSiteLaxAllows GET navigation, blocks POST etc. Recommended for most cases
SameSiteNoneCookie sent even on cross-site requests. Must use with Secure. Required for SSO
Domain.example.comCookie sharing across subdomains. Important for SSO
Path/authCookie sent only under this path
Max-Age3600Expiration time in seconds. 0 means immediate deletion
Expiresdate stringAbsolute expiration time. Max-Age takes precedence

Practical Configuration Example

Set-Cookie: access_token=eyJhbGciOi...;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900;
  Domain=.myservice.com

Set-Cookie: refresh_token=dGhpcyBpcyBh...;
  HttpOnly;
  Secure;
  SameSite=Strict;
  Path=/api/auth/refresh;
  Max-Age=604800;
  Domain=.myservice.com

Set-Cookie: csrf_token=abc123def456;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900;
  Domain=.myservice.com

Note: csrf_token is not HttpOnly. The frontend needs to read this value and include it in request headers (X-CSRF-Token), so JavaScript access is required.

SameSite Strategy Decision Tree:

Need SSO (cross-domain)?
├── YesSameSite=None; Secure (+ CSRF Token required)
└── No
    ├── Need to maintain login when arriving via external link?
    │   ├── YesSameSite=Lax
    │   └── NoSameSite=Strict
    └── API-only cookie?
        └── SameSite=Strict (most secure)

JWT Structure and Key Claims

JWT (JSON Web Token) consists of three parts separated by ..

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.     Header (Base64URL)
eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiQU...Payload (Base64URL)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ...Signature
{
  "alg": "RS256", // Signing algorithm (RS256, HS256, ES256, etc.)
  "typ": "JWT", // Token type
  "kid": "key-id-001" // Key ID (for identifying public key in JWK Set)
}

Payload (Claims)

{
  "sub": "user123", // Subject — unique user identifier
  "iss": "https://idp.myservice.com", // Issuer — token issuer
  "aud": "https://api.myservice.com", // Audience — intended recipient
  "exp": 1741420800, // Expiration — expiry time (Unix timestamp)
  "iat": 1741417200, // Issued At — issuance time
  "nbf": 1741417200, // Not Before — invalid before this time
  "jti": "unique-token-id-789", // JWT ID — unique token ID (replay prevention)
  "roles": ["ADMIN", "USER"], // Custom — user permissions
  "tenant_id": "tenant-abc", // Custom — multi-tenant identifier
  "email": "user@example.com" // Custom — user email
}

Signature Verification

RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  publicKey
)

JWT Parsing Example (Pseudocode)

import base64, json

def parse_jwt(token):
    header_b64, payload_b64, signature = token.split(".")

    # Base64URL decoding (padding correction)
    header = json.loads(
        base64.urlsafe_b64decode(header_b64 + "==")
    )
    payload = json.loads(
        base64.urlsafe_b64decode(payload_b64 + "==")
    )

    # Expiry verification
    import time
    if payload["exp"] < time.time():
        raise Exception("Token has expired")

    # Issuer verification
    if payload["iss"] != "https://idp.myservice.com":
        raise Exception("Untrusted issuer")

    return header, payload

Warning: In production environments, you must verify the signature. The above code is for structural understanding only. Use verified libraries such as PyJWT, jose, or jsonwebtoken.

JWT vs Session Comparison:

ItemJWT (Stateless)Session (Stateful)
Server storageNot requiredRequired (Redis, DB)
Horizontal scalingEasyRequires shared session store
Instant revocationDifficult (blocklist needed)Easy (delete session)
Token sizeLarge (includes claims)Small (session ID only)
Suitable scenarioMSA, API GatewayMonolith, single server

CORS and Credential Transmission

The area where most developers struggle with cookie-based authentication is CORS (Cross-Origin Resource Sharing) configuration. When frontend and backend domains differ, cookie transmission being blocked is a problem nearly every project encounters at least once.

Correct Configuration

Frontend (fetch API):

// credentials: 'include' is the key
const response = await fetch('https://api.myservice.com/users/me', {
  method: 'GET',
  credentials: 'include', // Send cookies along
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': getCsrfToken(), // CSRF token header
  },
})

Frontend (Axios):

const api = axios.create({
  baseURL: 'https://api.myservice.com',
  withCredentials: true, // Send cookies along
})

// Auto-attach CSRF token via interceptor
api.interceptors.request.use((config) => {
  const csrfToken = getCookieValue('csrf_token')
  if (csrfToken) {
    config.headers['X-CSRF-Token'] = csrfToken
  }
  return config
})

Backend response headers:

Access-Control-Allow-Origin: https://app.myservice.comExact Origin
Access-Control-Allow-Credentials: trueRequired
Access-Control-Allow-Headers: Content-Type, X-CSRF-TokenAllow custom headers
Access-Control-Allow-Methods: GET, POST, PUT, DELETEAllowed methods
Access-Control-Expose-Headers: X-Request-IdHeaders readable by frontend

Common Mistakes and Solutions

Mistake 1: Using Access-Control-Allow-Origin: * with credentials

# This will be blocked by the browser
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# → Error: Wildcard Origin not allowed in credentials mode

Solution: Specify the Origin exactly. If you need to support multiple Origins, check the request's Origin header and set it dynamically if it is on the whitelist.

# Django example
ALLOWED_ORIGINS = [
    'https://app.myservice.com',
    'https://admin.myservice.com',
]

# django-cors-headers configuration
CORS_ALLOWED_ORIGINS = ALLOWED_ORIGINS
CORS_ALLOW_CREDENTIALS = True

Mistake 2: Expecting cookies on Preflight (OPTIONS) requests

# Preflight (OPTIONS) requests do not include cookies!
# Do not require authentication on OPTIONS responses.
# → Exclude OPTIONS methods from your backend auth filters.

Mistake 3: SameSite=Strict cookies not sent on cross-origin requests

# Frontend: https://app.myservice.com
# Backend: https://api.myservice.com
# → Different Origins, so SameSite=Strict cookies are not sent
# → Use SameSite=None; Secure or the same parent domain

Mistake 4: localhost vs 127.0.0.1 in local dev

# localhost:3000 → localhost:8080 : Same site (cookies sent)
# localhost:3000127.0.0.1:8080 : Different site! (cookies not sent)
# → Use the same hostname even in development

Series Table of Contents

This series consists of 5 parts. After learning common concepts on this index page, move to the practical implementation for each framework.

PartTitleKey Content
0Current article (Index)Complete authentication flow map, common concepts
1Spring BootSecurityFilterChain, JWT filter, CORS setup, CSRF defense
2DjangoDRF + SimpleJWT, SessionMiddleware, django-cors-headers
3ReactAxios interceptors, Silent Refresh, Protected Route
4Next.jsMiddleware, Server Actions, SSR cookie passing, next-auth
5Integrated Practical Guide — SSO/OIDC + Hybrid ArchitectureKeycloak/Okta integration, BFF pattern, multi-service SSO

Recommended reading order:

  1. Grasp the overall flow from this index article.
  2. Read the backend framework part (Part 1 or Part 2) that you use.
  3. Read the frontend part (Part 3 or Part 4).
  4. Learn the architecture that integrates everything in Part 5.

Security Checklist (Series-Wide)

Security items that must be applied to all authentication systems regardless of framework. Always verify before deployment.

  • Is the HttpOnly attribute set on the Access Token cookie?
  • Is the Secure attribute set on all authentication cookies?
  • Is the SameSite attribute configured appropriately for the service architecture?
  • Is the Refresh Token cookie's Path restricted to the refresh endpoint?
  • Is the cookie's Max-Age set to an appropriate duration?
  • Is the cookie's Domain restricted to only the necessary scope?

Token Management

  • Is the Access Token expiration time short enough? (Recommended: 5-15 minutes)
  • Is the Refresh Token expiration time configured? (Recommended: 7-30 days)
  • Is Refresh Token Rotation implemented?
  • Is there a mechanism to revoke stolen Refresh Tokens?
  • Is the JWT signature using asymmetric algorithms like RS256 or ES256?
  • Are the JWT's iss, aud, and exp claims all being verified?

CSRF Defense

  • Are CSRF tokens verified on state-changing requests (POST, PUT, DELETE)?
  • Are CSRF tokens refreshed per request or per session?
  • Is the SameSite cookie attribute and CSRF token applied as dual protection?

CORS Configuration

  • Is Access-Control-Allow-Origin not using a wildcard (*)?
  • Is Access-Control-Allow-Credentials: true configured?
  • Is the allowed Origin list managed as a whitelist?
  • Are Preflight requests (OPTIONS) configured to pass without authentication?

Transport Security

  • Is all communication done over HTTPS?
  • Is the HSTS (HTTP Strict Transport Security) header configured?
  • Is TLS 1.2 or higher being used?

Logout

  • Are server-side sessions/tokens invalidated on logout?
  • Are all authentication cookies deleted on logout?
  • Is Single Logout (SLO) implemented in SSO environments?

Common Bugs and Misconceptions

Misconception 1: "JWT is encrypted"

JWT is signed, not encrypted. Base64URL encoding is not encryption. Anyone can decode the Payload and read its contents.

# Decoding JWT Payload (anyone can do this)
echo "eyJzdWIiOiJ1c2VyMTIzIn0" | base64 -d
# Output: {"sub":"user123"}

Never put sensitive information (passwords, social security numbers, etc.) in the JWT Payload. If you truly need encryption, use JWE (JSON Web Encryption).

Misconception 2: "It is okay to store JWT in localStorage"

Many SPA framework tutorials show examples of storing JWT in localStorage. This is vulnerable to XSS attacks. A vulnerability in a single third-party library can lead to token theft.

// Dangerous pattern
localStorage.setItem('token', jwt)

// Safe pattern: Use HttpOnly Cookie (set by server)
// Frontend does not handle tokens directly

Misconception 3: "SameSite=Lax completely defends against CSRF"

SameSite=Lax defends against most CSRF but is not complete. Cookies are sent on cross-site GET requests. If you have an API that changes state via GET requests, it is still vulnerable.

# GET /api/users/delete?id=123SameSite=Lax cannot defend against this!
# → State changes must always use POST/PUT/DELETE

Misconception 4: "Refresh Token is more secure than Access Token"

Refresh Tokens are valid for longer periods, so if stolen they are more dangerous than Access Tokens. A stolen Refresh Token allows an attacker to issue new Access Tokens indefinitely.

Defense strategies:

  • Refresh Token Rotation: Immediately revoke previous token on refresh
  • Token Family tracking: If an already-used Refresh Token is used again, revoke the entire Family
  • Store Refresh Token in HttpOnly Cookie with Path restriction

Misconception 5: "CORS protects the server"

CORS is a browser policy. CORS does not apply to curl, Postman, or server-to-server communication. CORS only prevents malicious websites from using the user's browser to call APIs -- it does not protect the API itself.

# Works regardless of CORS
curl -X POST https://api.myservice.com/data \
  -H "Cookie: access_token=stolen_token"
# → Server-side token verification is essential

Common Bug: Token Refresh Race Condition

When multiple API requests simultaneously receive 401, multiple Refresh requests occur. When using Refresh Token Rotation, only the first request succeeds and the rest fail.

// Solution: Pattern to consolidate refresh requests into one
let refreshPromise = null

async function refreshToken() {
  if (refreshPromise) return refreshPromise // Wait if already refreshing

  refreshPromise = axios.post('/auth/refresh').finally(() => {
    refreshPromise = null
  })

  return refreshPromise
}

References

Official documents and standard specifications referenced while writing this series. Always check the original sources when designing authentication systems.

Standard Specifications (RFC)

  1. RFC 7519 — JSON Web Token (JWT) — JWT standard specification. Defines claims, signatures, and verification procedures
  2. RFC 6749 — OAuth 2.0 Authorization Framework — Core OAuth 2.0 framework. Defines flows for each Grant Type
  3. RFC 6750 — OAuth 2.0 Bearer Token Usage — Bearer Token transmission methods (Authorization header, query parameters, etc.)
  4. RFC 7517 — JSON Web Key (JWK) — JWK Set standard for public key distribution
  5. RFC 6265 — HTTP State Management Mechanism (Cookie) — HTTP cookie standard specification

OpenID Connect

  1. OpenID Connect Core 1.0 — Standard that adds an authentication layer on top of OAuth 2.0

Security Guidelines

  1. OWASP — Session Management Cheat Sheet — Session management security best practices
  2. OWASP — JSON Web Token Cheat Sheet — JWT security checklist
  3. OWASP — Cross-Site Request Forgery Prevention — CSRF defense strategies

Framework Official Documentation

  1. MDN — HTTP Cookies — Detailed explanation of cookie attributes (SameSite, HttpOnly, Secure, etc.)
  2. Spring Security — OAuth 2.0 Resource Server — Official Spring Boot JWT authentication guide
  3. Django REST Framework — Authentication — DRF authentication mechanisms official documentation
  4. Next.js — Authentication — Next.js App Router based authentication implementation guide
  5. MDN — CORS — Complete Cross-Origin Resource Sharing guide