- Overview - SSO and Hybrid Authentication Architecture
- OIDC/OAuth2 Protocol Deep Dive
- Hybrid Architecture Design
- SSO Implementation in Practice
- Token Rotation Strategy
- Cookie/JWT Hybrid Pattern Details
- Browser Storage Accessibility Table
- CORS + Multi-domain SSO
- Logout Strategy
- Token Invalidation and Blacklist
- Multi-service Authentication (Microservices)
- Comprehensive Security Tradeoffs
- Framework Comparison Summary Table
- Integration Checklist
- Common Bugs and Misconceptions
- 1. "OAuth 2.0 is an authentication protocol" - no, it is not
- 2. "JWT is always stateless" - reality differs
- 3. Is it fine to store the Access Token in localStorage?
- 4. Is a Refresh Token safe as long as you only set a long expiry?
- 5. Using Access-Control-Allow-Origin: * in the CORS configuration?
- 6. Is SameSite=Strict the safest setting?
- 7. Using the ID Token for API calls?
- References
SSO Cookie/JWT Authentication Series · Next.js Edition · Current: Integration Practical Edition · Series Index
Overview - SSO and Hybrid Authentication Architecture
What Is SSO
Single Sign-On (SSO) is an authentication mechanism that lets a user access multiple applications and services with a single set of credentials. Once you log in, you can reach email, the wiki, CI/CD, internal admin tools and the rest of the same organization's services without logging in again.
[User] ──login──▶ [IdP (Identity Provider)]
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
[App A] [App B] [App C]
(Email) (Wiki) (CI/CD)
no re-login no re-login no re-login
OIDC vs OAuth2 Differences
This is where many developers get confused, so let us draw the line clearly.
- OAuth 2.0: an authorization framework. It decides "may this app access my resources?" It issues an Access Token, and it does not define who the user is in any standard way.
- OpenID Connect (OIDC): an authentication layer built on top of OAuth 2.0. It issues an ID Token (JWT) to prove "who this user is".
OAuth 2.0 = "May this app read your Google Drive?" (authorization)
OIDC = "The person who logged in is youngjukim@example.com" (authentication) + OAuth 2.0
Why Is a Hybrid (Cookie+JWT) Architecture Needed
In a real production environment, using cookies alone or JWT alone each has its own limits.
- Cookies only: natural between browser and server, but a poor fit for propagation between microservices and hard to use from a mobile app.
- JWT only: good for propagation between services, but hard to store safely in the browser (XSS-vulnerable), and immediate invalidation is not possible.
The hybrid architecture uses an HttpOnly session cookie on the browser ↔ BFF segment and JWT on the BFF ↔ backend service segment, taking the advantages of both.
Authentication Challenges in Microservices Environments
- If dozens of services each implement authentication on their own, the maintenance cost grows exponentially.
- Token propagation, audience verification and token-expiry handling have to be unified across service-to-service calls.
- Session consistency: logging out of one service must log the user out of every other service immediately.
OIDC/OAuth2 Protocol Deep Dive
Authorization Code Flow + PKCE
This is the most secure authentication flow, and it is recommended for every client type (web, mobile, SPA).
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ BFF │ │ IdP │
│ (User) │ │ (Server) │ │(Keycloak)│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 1. Click /login │ │
│─────────────────────────────────────▶│ │
│ │ 2. Generate code_verifier │
│ │ code_challenge = │
│ │ SHA256(code_verifier) │
│ 3. 302 Redirect to IdP │ │
│◀─────────────────────────────────────│ │
│ (/authorize?response_type=code │ │
│ &client_id=... │ │
│ &code_challenge=... │ │
│ &code_challenge_method=S256) │ │
│──────────────────────────────────────────────────────────────────────▶│
│ │ 4. User login + consent │
│◀──────────────────────────────────────────────────────────────────────│
│ 5. redirect_uri?code=AUTH_CODE │ │
│─────────────────────────────────────▶│ │
│ │ 6. POST /token │
│ │ code + code_verifier │
│ │─────────────────────────────▶│
│ │ 7. AT + RT + ID Token │
│ │◀─────────────────────────────│
│ 8. Set-Cookie: session_id │ │
│◀─────────────────────────────────────│ │
PKCE (Proof Key for Code Exchange, RFC 7636) defends against attacks that intercept the Authorization Code.
# Python: generating the PKCE code_verifier / code_challenge
import secrets
import hashlib
import base64
# 1. code_verifier: a random string of 43-128 characters
code_verifier = secrets.token_urlsafe(64)[:128]
# 2. code_challenge: SHA256 hash, then Base64url encoding
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
print(f"code_verifier: {code_verifier}")
print(f"code_challenge: {code_challenge}")
ID Token vs Access Token vs Refresh Token
| Token | Purpose | Lifetime | Audience | Format |
|---|---|---|---|---|
| ID Token | Deliver user auth info | 5-60 min | Client app | JWT (Required) |
| Access Token | Grant API access | 5-60 min | Resource server | JWT or Opaque |
| Refresh Token | Issue new AT/RT | Days to months | Authorization Server only | Opaque Recommended |
Key claims in an ID Token:
{
"iss": "https://idp.example.com",
"sub": "user-uuid-1234",
"aud": "my-client-id",
"exp": 1709913600,
"iat": 1709910000,
"nonce": "abc123xyz",
"email": "youngjukim@example.com",
"name": "Youngju Kim",
"email_verified": true
}
sub: the user's unique identifier (never changes)aud: the client ID this token was issued to (must be verified)nonce: guards against replay attacks (check it matches the value sent with the authentication request)
Example Access Token scope:
scope: "openid profile email read:calendar write:calendar"
Hybrid Architecture Design
BFF (Backend For Frontend) Pattern
The BFF pattern is the core of hybrid authentication. Tokens exist only on the BFF server, and only a session cookie is handed to the browser.
┌─────────────────────────────────────────────────────┐
│ Browser │
│ Session cookie only (HttpOnly, Secure, SameSite=Lax) │
│ No token access from JavaScript → safe from XSS │
└──────────────────────┬──────────────────────────────┘
│ Cookie: session_id=xxx
▼
┌──────────────────────────────────────────────────────┐
│ BFF (Backend For Frontend) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Session store (Redis) │ │
│ │ session_id → { access_token, refresh_token, │ │
│ │ id_token, user_info } │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────┘
│ Authorization: Bearer <JWT>
▼
┌──────────────────────────────────────────────────────┐
│ Backend Microservices │
│ [User API] [Order API] [Payment API] [...] │
└──────────────────────────────────────────────────────┘
Security benefits:
- The Access Token and Refresh Token are never exposed to the browser, so an XSS attack cannot steal them
- CSRF defense is handled with SameSite cookies plus a CSRF token
- Token renewal logic is concentrated on the server, reducing client complexity
Token Relay Pattern
The API Gateway converts the session cookie into a JWT and passes it to the backend.
# Spring Cloud Gateway - Token Relay configuration
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- TokenRelay= # automatically adds the Access Token to the Authorization header
- RemoveRequestHeader=Cookie # the cookie is not forwarded to the backend
SSO Implementation in Practice
Keycloak Integration Example
Spring Boot + Spring Security OAuth2 Client:
// application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-app
client-secret: ${KEYCLOAK_SECRET}
scope: openid, profile, email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://keycloak.example.com/realms/my-realm
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/dashboard")
)
.oauth2Client(Customizer.withDefaults());
return http.build();
}
}
Django + mozilla-django-oidc:
# settings.py
INSTALLED_APPS += ['mozilla_django_oidc']
AUTHENTICATION_BACKENDS = [
'mozilla_django_oidc.auth.OIDCAuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
]
OIDC_RP_CLIENT_ID = os.environ['OIDC_CLIENT_ID']
OIDC_RP_CLIENT_SECRET = os.environ['OIDC_CLIENT_SECRET']
OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth'
OIDC_OP_TOKEN_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token'
OIDC_OP_USER_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/userinfo'
OIDC_OP_JWKS_ENDPOINT = 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs'
OIDC_RP_SIGN_ALGO = 'RS256'
# urls.py
urlpatterns += [
path('oidc/', include('mozilla_django_oidc.urls')),
]
Next.js + NextAuth.js OIDC Provider:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import KeycloakProvider from 'next-auth/providers/keycloak'
const handler = NextAuth({
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER, // https://keycloak.example.com/realms/my-realm
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at
}
// renew before expiry
if (Date.now() < (token.expiresAt as number) * 1000) {
return token
}
return refreshAccessToken(token)
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
return session
},
},
})
export { handler as GET, handler as POST }
Google/Azure AD/Okta Integration
If you make use of the OIDC Discovery endpoint (.well-known/openid-configuration), you can integrate any IdP with the same pattern.
Google: https://accounts.google.com/.well-known/openid-configuration
Azure AD: https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
Okta: https://{domain}.okta.com/.well-known/openid-configuration
Keycloak: https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration
// Generic use of OIDC Discovery (Node.js)
import { Issuer } from 'openid-client'
async function setupOIDC(issuerUrl: string, clientId: string, clientSecret: string) {
const issuer = await Issuer.discover(issuerUrl)
console.log('Authorization Endpoint:', issuer.metadata.authorization_endpoint)
console.log('Token Endpoint:', issuer.metadata.token_endpoint)
console.log('UserInfo Endpoint:', issuer.metadata.userinfo_endpoint)
console.log('JWKS URI:', issuer.metadata.jwks_uri)
const client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: ['https://myapp.example.com/callback'],
response_types: ['code'],
})
return client
}
Token Rotation Strategy
Refresh Token Rotation
Refresh Token Rotation is the core security mechanism for detecting that a stolen token is being reused.
[Normal flow]
Client → POST /token (grant_type=refresh_token, refresh_token=RT_1)
Server → issues a new AT_2 + new RT_2, invalidates RT_1
[Theft scenario — Automatic Reuse Detection]
An attacker steals RT_1 and then uses it:
Attacker → POST /token (refresh_token=RT_1) ← an already-used RT!
Server → detects that RT_1 has already been used
→ invalidates the entire token family of RT_1 (RT_2, RT_3 ...)
→ forces the user to log in again
// Node.js / Express: implementing Refresh Token Rotation
import { randomUUID } from 'crypto'
import Redis from 'ioredis'
const redis = new Redis()
interface TokenFamily {
userId: string
familyId: string
usedTokens: Set<string>
}
async function rotateRefreshToken(currentRefreshToken: string) {
const tokenData = await redis.get(`rt:${currentRefreshToken}`)
if (!tokenData) {
throw new Error('INVALID_REFRESH_TOKEN')
}
const parsed = JSON.parse(tokenData)
const familyKey = `family:${parsed.familyId}`
// Reuse Detection: if the token was already used, invalidate the whole family
const isUsed = await redis.sismember(`${familyKey}:used`, currentRefreshToken)
if (isUsed) {
console.warn(`[SECURITY] Token reuse detected! Family: ${parsed.familyId}`)
await revokeTokenFamily(parsed.familyId)
throw new Error('TOKEN_REUSE_DETECTED')
}
// mark the current RT as "used"
await redis.sadd(`${familyKey}:used`, currentRefreshToken)
// issue new tokens
const newRefreshToken = randomUUID()
const newAccessToken = generateJWT(parsed.userId)
await redis.setex(
`rt:${newRefreshToken}`,
7 * 24 * 3600,
JSON.stringify({
userId: parsed.userId,
familyId: parsed.familyId,
createdAt: Date.now(),
})
)
// delete the previous RT
await redis.del(`rt:${currentRefreshToken}`)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
async function revokeTokenFamily(familyId: string) {
const members = await redis.smembers(`family:${familyId}:used`)
const pipeline = redis.pipeline()
for (const token of members) {
pipeline.del(`rt:${token}`)
}
pipeline.del(`family:${familyId}:used`)
await pipeline.exec()
}
Access Token Renewal Timing
| Strategy | Method | Pros | Cons |
|---|---|---|---|
| Proactive (pre-renewal) | Pre-renew 30-60s before expiry | No user experience interruption | Possible unnecessary renewal |
| Reactive (on-demand) | Renew and retry on 401 response | Simple implementation | First request fails + delay |
| Hybrid | Timer-based pre-renewal + 401 fallback | Combines both advantages | Increased complexity |
// Axios interceptor: the Hybrid renewal strategy
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
let isRefreshing = false
let failedQueue: Array<{ resolve: Function; reject: Function }> = []
const api = axios.create({ baseURL: '/api', withCredentials: true })
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// a renewal is already in flight, so queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(() => api(originalRequest))
}
originalRequest._retry = true
isRefreshing = true
try {
await axios.post('/api/auth/refresh', {}, { withCredentials: true })
failedQueue.forEach(({ resolve }) => resolve())
failedQueue = []
return api(originalRequest)
} catch (refreshError) {
failedQueue.forEach(({ reject }) => reject(refreshError))
failedQueue = []
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
Cookie/JWT Hybrid Pattern Details
The storage strategy recommended in practice is as follows.
| Data | Storage Location | Reason |
|---|---|---|
| Session ID | HttpOnly Secure cookie | No JS access, sent automatically |
| Access Token | BFF server memory/Redis | Prevent browser exposure |
| Refresh Token | BFF server Redis (encrypted) | Long-lived tokens must be stored on server |
| CSRF Token | non-HttpOnly cookie or a header | Read from JS and include in header |
Set-Cookie configuration in practice:
// Express.js: session cookie configuration (BFF)
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'
const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()
app.use(
session({
store: new RedisStore({ client: redisClient }),
name: '__Host-session', // __Host- prefix: Secure + pinned to one specific domain
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // no JavaScript access
secure: true, // sent over HTTPS only
sameSite: 'lax', // CSRF defense: blocks sending from external sites
maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/',
// domain omitted → applies to the current host only (together with the __Host- prefix)
},
})
)
// Example of the resulting Set-Cookie header:
// Set-Cookie: __Host-session=s%3Aabc123...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
Browser Storage Accessibility Table
| Storage | JS Access | Auto Server Send | XSS Vuln. | CSRF Vuln. | Recommended Use |
|---|---|---|---|---|---|
| HttpOnly cookie | Not possible | Auto (same-origin) | Safe | Vulnerable (defended by SameSite) | Session ID, Refresh Token |
| Regular cookie | Possible | Auto | Vulnerable | Vulnerable | CSRF Token (for reading) |
| localStorage | Possible | Manual | Vulnerable | Safe | Non-sensitive settings |
| sessionStorage | Possible | Manual | Vulnerable | Safe | Per-tab temporary data |
| Memory (a var) | Possible | Manual | Relatively safe | Safe | Access Token in an SPA |
The core principle: store sensitive tokens in HttpOnly cookies or in the server session, and do not handle tokens directly from JavaScript.
CORS + Multi-domain SSO
Subdomain Cookie Sharing
# Sharing a cookie across subdomains with the Domain attribute
Set-Cookie: session=abc; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=Lax
→ the cookie is sent from app1.example.com, app2.example.com and admin.example.com alike
Cross-origin Credential Transmission
// Frontend: setting credentials: 'include' is required
fetch('https://api.example.com/data', {
method: 'GET',
credentials: 'include', // send the cookie cross-origin
})
// Backend: CORS configuration (Express)
app.use(
cors({
origin: 'https://app.example.com', // '*' cannot be used (when credentials are used)
credentials: true, // Access-Control-Allow-Credentials: true
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
})
)
Third-party Cookie Restrictions and Countermeasures
The major browsers are blocking or restricting third-party cookies. You have to set SameSite=None; Secure for cross-site transmission to be possible, and even that is being restricted more and more.
Countermeasures:
- Consolidate every service onto the same domain (subdomains)
- Move to token-based SSO (use URL parameters or postMessage instead of cookies)
- Use a proxy pattern through a BFF
Logout Strategy
Local Logout
// Express: local logout (delete the session + the cookie)
app.post('/logout', async (req, res) => {
const sessionData = req.session
// 1. revoke the token at the IdP (optional but recommended)
if (sessionData?.refreshToken) {
await fetch(`${ISSUER}/protocol/openid-connect/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: sessionData.refreshToken,
token_type_hint: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
})
}
// 2. destroy the session
req.session.destroy((err) => {
if (err) console.error('Session destroy error:', err)
// 3. delete the cookie
res.clearCookie('__Host-session', { path: '/', httpOnly: true, secure: true })
res.json({ success: true })
})
})
SSO Global Logout
OIDC end_session_endpoint:
// Redirect the user to the IdP's logout page
const logoutUrl = new URL(`${ISSUER}/protocol/openid-connect/logout`)
logoutUrl.searchParams.set('id_token_hint', idToken)
logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com')
res.redirect(logoutUrl.toString())
Back-Channel Logout (IdP → calls each service directly):
// POST /backchannel-logout endpoint (implemented in each service)
app.post('/backchannel-logout', async (req, res) => {
const { logout_token } = req.body
// verify the logout_token (JWT)
const decoded = await verifyLogoutToken(logout_token)
const userId = decoded.sub
const sessionId = decoded.sid
// invalidate every session belonging to that user
await redis.del(`user_sessions:${userId}`)
console.log(`[Back-Channel Logout] User ${userId} session ${sessionId} invalidated`)
res.sendStatus(200)
})
Front-Channel Logout (IdP → calls each service's logout URL in an iframe):
<!-- each RP's logout iframe embedded in the logout page the IdP renders -->
<iframe src="https://app1.example.com/logout?sid=xxx" width="0" height="0"></iframe>
<iframe src="https://app2.example.com/logout?sid=xxx" width="0" height="0"></iframe>
Note: Back-Channel Logout is more reliable than Front-Channel. Front-Channel does not run once the browser is closed, and it is affected by third-party cookie restrictions.
Token Invalidation and Blacklist
Stateless JWT Invalidation Limitations
A JWT is a self-contained token that can be verified from its signature alone, so once issued it cannot be invalidated on the server before it expires. This is the intrinsic limitation of JWT.
Redis-based Blacklist
// JWT blacklist middleware
async function jwtBlacklistMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return res.sendStatus(401)
// check the blacklist (by jti or by token hash)
const decoded = jwt.decode(token) as { jti: string; exp: number }
const isBlacklisted = await redis.exists(`blacklist:${decoded.jti}`)
if (isBlacklisted) {
return res.status(401).json({ error: 'Token has been revoked' })
}
// normal JWT verification
try {
req.user = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] })
next()
} catch {
return res.sendStatus(401)
}
}
// register on the blacklist (logout, or forced invalidation by an admin)
async function revokeToken(token: string) {
const decoded = jwt.decode(token) as { jti: string; exp: number }
const ttl = decoded.exp - Math.floor(Date.now() / 1000)
if (ttl > 0) {
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1')
}
}
Token Introspection (RFC 7662)
When you use opaque tokens, this is how the resource server asks the Authorization Server directly whether a token is still valid.
POST /token/introspect HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=abc123opaque_token
→ Response:
{
"active": true,
"sub": "user-1234",
"scope": "openid profile",
"client_id": "my-app",
"exp": 1709913600
}
The combination recommended in practice: a short-lived Access Token (5-15 min) plus Refresh Token Rotation gets you the practical effect of invalidation even without a blacklist. A short AT expires quickly even if it is stolen, and rotation detects reuse of the RT.
Multi-service Authentication (Microservices)
JWT Verification at the API Gateway
# Kong Gateway: JWT verification plugin configuration
plugins:
- name: jwt
config:
uri_param_names: []
header_names: ['Authorization']
claims_to_verify:
- exp
key_claim_name: iss
secret_is_base64: false
// Go: API Gateway JWT verification middleware
func JWTMiddleware(jwksURL string) func(http.Handler) http.Handler {
keySet, _ := jwk.Fetch(context.Background(), jwksURL)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
token, err := jwt.Parse([]byte(tokenStr), jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithAudience("my-api"),
)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user", token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Using Audience (aud) Claim
When a token is propagated between services, the aud claim restricts the token's intended recipient.
User → BFF: aud=["bff-service"]
BFF → User Service: aud=["user-service"] (token exchange, or a newly issued token)
BFF → Order Service: aud=["order-service"]
Each service must verify that its own aud value is present in the token. That is what stops a token meant for the User Service from being abused at the Order Service.
Service Mesh (Istio) mTLS
Service-to-service communication gets its transport-layer security from Istio mTLS, and JWT is used for application-layer authorization.
# Istio: RequestAuthentication + AuthorizationPolicy
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: jwt-auth
spec:
jwtRules:
- issuer: 'https://idp.example.com'
jwksUri: 'https://idp.example.com/.well-known/jwks.json'
forwardOriginalToken: true
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: require-jwt
spec:
rules:
- from:
- source:
requestPrincipals: ['*']
when:
- key: request.auth.claims[aud]
values: ['order-service']
Comprehensive Security Tradeoffs
| Threat | Cookie Only | JWT Only (localStorage) | Hybrid (BFF) |
|---|---|---|---|
| XSS | Safe with HttpOnly | Very vulnerable (token theft) | Safe with HttpOnly cookie |
| CSRF | Vulnerable (mitigated by SameSite) | Safe (no cookies used) | Defended with SameSite + CSRF token |
| Token Theft | Only session ID exposed | Both AT+RT exposure risk | Tokens exist only on server |
| Replay Attack | Session binding | Reusable if AT stolen | Short-lived AT + server verification |
| Session Fixation | Defended by session regeneration after login | Not applicable | Defended by session regeneration |
| Immediate token invalid. | Possible by session deletion | Impossible (wait until expiry) | Possible with session+blacklist |
The Defense in Depth principle: do not rely on a single line of defense. Layer HttpOnly cookies + SameSite + a CSRF Token + CSP (Content Security Policy) + short-lived tokens + Token Rotation on top of one another.
Framework Comparison Summary Table
| Category | Spring Boot | Django | React (SPA) | Next.js (App Router) |
|---|---|---|---|---|
| Token Storage | Server session (HttpSession) | Server session (DB/Redis) | Memory variable | Server session (NextAuth) |
| JWT Verify | spring-security-oauth2-resource-server | PyJWT / djangorestframework-simplejwt | Not needed (with BFF pattern) | jose library (server side) |
| Middleware | SecurityFilterChain | DjangoMiddleware | Axios interceptor | middleware.ts |
| CSRF Defense | CsrfFilter (automatic) | CsrfViewMiddleware (automatic) | Not needed without cookies | Manual CSRF Token implementation |
| Logout | OidcClientInitiatedLogoutSuccessHandler | mozilla-django-oidc logout view | Memory token deletion | signOut() + IdP logout |
| Session Store | Redis (Spring Session) | Redis (django-redis) | Not applicable | Redis / DB |
Integration Checklist
These are the items you must check when implementing SSO + hybrid authentication.
- IdP settings loaded automatically through the OIDC Discovery endpoint
- Authorization Code Flow + PKCE applied
- ID Token verification: check the
iss,aud,expandnonceclaims - The Access Token is not exposed to the browser (BFF pattern)
- The Refresh Token is stored in an HttpOnly Secure cookie or in the server session
- Refresh Token Rotation enabled + Reuse Detection implemented
- Access Token lifetime set to 15 minutes or less
- Session cookie:
HttpOnly,Secure,SameSite=Lax,__Host-prefix - CSRF defense: SameSite cookie + Double Submit Cookie or Synchronizer Token
- CORS configuration: with
credentials: true, name the origin explicitly (*is forbidden) - JWKS caching and handling of automatic rotation
- Global logout (Back-Channel Logout recommended)
- Immediate invalidation through Token Introspection or a blacklist
- The
audclaim verified when a token is propagated between services - CSP (Content-Security-Policy) header configured
- HTTPS only (HTTP access blocked)
- Rate limiting applied on failed logins
- Sensitive log masking (never leave token values in logs)
Common Bugs and Misconceptions
1. "OAuth 2.0 is an authentication protocol" - no, it is not
OAuth 2.0 is an authorization framework. It does not tell you who the user is in any standard way. If you need authentication, you have to use OIDC. Implementing "login" with an Access Token alone opens up a security hole.
2. "JWT is always stateless" - reality differs
In theory a JWT is stateless, but in real production a blacklist, a session store, Token Introspection and similar stateful pieces are all but mandatory. "Pure stateless JWT" makes immediate invalidation impossible, which makes responding to a security incident hard.
3. Is it fine to store the Access Token in localStorage?
Never do it. A single XSS attack steals the token. Use the BFF pattern to keep tokens on the server and hand the browser nothing but an HttpOnly session cookie.
4. Is a Refresh Token safe as long as you only set a long expiry?
A Refresh Token can be abused for a long time once it is stolen. You must apply Token Rotation + Reuse Detection, and add device/IP binding where possible.
5. Using Access-Control-Allow-Origin: * in the CORS configuration?
If you use the wildcard (*) together with credentials: true, the browser rejects the request. You have to name the exact origin. Beyond that, a wildcard origin is a security risk in itself.
6. Is SameSite=Strict the safest setting?
SameSite=Strict does not send the cookie even when the user arrives via a link from an external site, which seriously harms the SSO user experience. In most cases SameSite=Lax is the appropriate choice.
7. Using the ID Token for API calls?
The ID Token exists so that the client app can confirm the user's information. For API calls you must use the Access Token. The aud of an ID Token is the client app, not the API server.
References
- OpenID Connect Core 1.0 Specification
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 6750 - OAuth 2.0 Bearer Token Usage
- RFC 7519 - JSON Web Token (JWT)
- RFC 7662 - OAuth 2.0 Token Introspection
- RFC 7636 - Proof Key for Code Exchange (PKCE)
- OpenID Connect RP-Initiated Logout
- OpenID Connect Back-Channel Logout
- Keycloak Documentation - Securing Applications
- Auth0 - Token Best Practices
- Auth0 - Refresh Token Rotation
- OWASP - Session Management Cheat Sheet
- OWASP - JSON Web Token Cheat Sheet
- Spring Security OAuth2 Resource Server Reference
- NextAuth.js Documentation
- Mozilla Django OIDC Documentation
- RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- Istio Security - Request Authentication