Skip to content
Published on

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

Authors

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