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

- Name
- Youngju Kim
- @fjvbn20031
- Series Introduction — Why This Series
- Complete Authentication Flow Map
- Browser Storage Access Characteristics
- Cookie Security Attributes Quick Reference
- JWT Structure and Key Claims
- CORS and Credential Transmission
- Series Table of Contents
- Security Checklist (Series-Wide)
- Common Bugs and Misconceptions
- Misconception 1: "JWT is encrypted"
- Misconception 2: "It is okay to store JWT in localStorage"
- Misconception 3: "SameSite=Lax completely defends against CSRF"
- Misconception 4: "Refresh Token is more secure than Access Token"
- Misconception 5: "CORS protects the server"
- Common Bug: Token Refresh Race Condition
- References
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. Code → Token │ │
│ │◀───────────────────│ │
│ │ │ (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.
| Storage | JS Access | Auto-sent to Server | XSS Vulnerable | CSRF Vulnerable |
|---|---|---|---|---|
| HttpOnly Cookie | No | Yes | No | Yes |
| Non-HttpOnly Cookie | Yes | Yes | Yes | Yes |
| localStorage | Yes | No | Yes | No |
| sessionStorage | Yes | No | Yes | No |
| Memory (JS variable) | Yes | No | Partial | No |
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.
Cookie Security Attributes Quick Reference
Cookies are not just simple key=value pairs. Missing a single security attribute can bring down the entire authentication system.
Detailed Attribute Descriptions
| Attribute | Value | Description |
|---|---|---|
HttpOnly | true | Blocks JavaScript access. Core of XSS defense |
Secure | true | Cookie sent only over HTTPS connections |
SameSite | Strict | Cookie not sent on any cross-site request. Most secure but login drops on external links |
SameSite | Lax | Allows GET navigation, blocks POST etc. Recommended for most cases |
SameSite | None | Cookie sent even on cross-site requests. Must use with Secure. Required for SSO |
Domain | .example.com | Cookie sharing across subdomains. Important for SSO |
Path | /auth | Cookie sent only under this path |
Max-Age | 3600 | Expiration time in seconds. 0 means immediate deletion |
Expires | date string | Absolute 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_tokenis 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)?
├── Yes → SameSite=None; Secure (+ CSRF Token required)
└── No
├── Need to maintain login when arriving via external link?
│ ├── Yes → SameSite=Lax
│ └── No → SameSite=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
Header
{
"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, orjsonwebtoken.
JWT vs Session Comparison:
| Item | JWT (Stateless) | Session (Stateful) |
|---|---|---|
| Server storage | Not required | Required (Redis, DB) |
| Horizontal scaling | Easy | Requires shared session store |
| Instant revocation | Difficult (blocklist needed) | Easy (delete session) |
| Token size | Large (includes claims) | Small (session ID only) |
| Suitable scenario | MSA, API Gateway | Monolith, 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.com ← Exact Origin
Access-Control-Allow-Credentials: true ← Required
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token ← Allow custom headers
Access-Control-Allow-Methods: GET, POST, PUT, DELETE ← Allowed methods
Access-Control-Expose-Headers: X-Request-Id ← Headers 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:3000 → 127.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.
| Part | Title | Key Content |
|---|---|---|
| 0 | Current article (Index) | Complete authentication flow map, common concepts |
| 1 | Spring Boot | SecurityFilterChain, JWT filter, CORS setup, CSRF defense |
| 2 | Django | DRF + SimpleJWT, SessionMiddleware, django-cors-headers |
| 3 | React | Axios interceptors, Silent Refresh, Protected Route |
| 4 | Next.js | Middleware, Server Actions, SSR cookie passing, next-auth |
| 5 | Integrated Practical Guide — SSO/OIDC + Hybrid Architecture | Keycloak/Okta integration, BFF pattern, multi-service SSO |
Recommended reading order:
- Grasp the overall flow from this index article.
- Read the backend framework part (Part 1 or Part 2) that you use.
- Read the frontend part (Part 3 or Part 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.
Cookie Configuration
- Is the
HttpOnlyattribute set on the Access Token cookie? - Is the
Secureattribute set on all authentication cookies? - Is the
SameSiteattribute configured appropriately for the service architecture? - Is the Refresh Token cookie's
Pathrestricted to the refresh endpoint? - Is the cookie's
Max-Ageset to an appropriate duration? - Is the cookie's
Domainrestricted 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, andexpclaims 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
SameSitecookie attribute and CSRF token applied as dual protection?
CORS Configuration
- Is
Access-Control-Allow-Originnot using a wildcard (*)? - Is
Access-Control-Allow-Credentials: trueconfigured? - 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=123 ← SameSite=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)
- RFC 7519 — JSON Web Token (JWT) — JWT standard specification. Defines claims, signatures, and verification procedures
- RFC 6749 — OAuth 2.0 Authorization Framework — Core OAuth 2.0 framework. Defines flows for each Grant Type
- RFC 6750 — OAuth 2.0 Bearer Token Usage — Bearer Token transmission methods (Authorization header, query parameters, etc.)
- RFC 7517 — JSON Web Key (JWK) — JWK Set standard for public key distribution
- RFC 6265 — HTTP State Management Mechanism (Cookie) — HTTP cookie standard specification
OpenID Connect
- OpenID Connect Core 1.0 — Standard that adds an authentication layer on top of OAuth 2.0
Security Guidelines
- OWASP — Session Management Cheat Sheet — Session management security best practices
- OWASP — JSON Web Token Cheat Sheet — JWT security checklist
- OWASP — Cross-Site Request Forgery Prevention — CSRF defense strategies
Framework Official Documentation
- MDN — HTTP Cookies — Detailed explanation of cookie attributes (SameSite, HttpOnly, Secure, etc.)
- Spring Security — OAuth 2.0 Resource Server — Official Spring Boot JWT authentication guide
- Django REST Framework — Authentication — DRF authentication mechanisms official documentation
- Next.js — Authentication — Next.js App Router based authentication implementation guide
- MDN — CORS — Complete Cross-Origin Resource Sharing guide