LabHub

Blog

SSO Integration Practical Guide - OIDC/OAuth2 + Cookie/JWT Hybrid Architecture, Token Rotation Complete Mastery

한국어English日本語

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 = "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.

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


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

TokenPurposeLifetimeAudienceFormat
ID TokenDeliver user auth info5-60 minClient appJWT (Required)
Access TokenGrant API access5-60 minResource serverJWT or Opaque
Refresh TokenIssue new AT/RTDays to monthsAuthorization Server onlyOpaque 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
}

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.

┌─────────────────────────────────────────────────────┐
BrowserSession 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:

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]
ClientPOST /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:
  AttackerPOST /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

StrategyMethodProsCons
Proactive (pre-renewal)Pre-renew 30-60s before expiryNo user experience interruptionPossible unnecessary renewal
Reactive (on-demand)Renew and retry on 401 responseSimple implementationFirst request fails + delay
HybridTimer-based pre-renewal + 401 fallbackCombines both advantagesIncreased 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.

DataStorage LocationReason
Session IDHttpOnly Secure cookieNo JS access, sent automatically
Access TokenBFF server memory/RedisPrevent browser exposure
Refresh TokenBFF server Redis (encrypted)Long-lived tokens must be stored on server
CSRF Tokennon-HttpOnly cookie or a headerRead 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

StorageJS AccessAuto Server SendXSS Vuln.CSRF Vuln.Recommended Use
HttpOnly cookieNot possibleAuto (same-origin)SafeVulnerable (defended by SameSite)Session ID, Refresh Token
Regular cookiePossibleAutoVulnerableVulnerableCSRF Token (for reading)
localStoragePossibleManualVulnerableSafeNon-sensitive settings
sessionStoragePossibleManualVulnerableSafePer-tab temporary data
Memory (a var)PossibleManualRelatively safeSafeAccess 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

# 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'],
  })
)

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:


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.

UserBFF:           aud=["bff-service"]
BFFUser Service:   aud=["user-service"]     (token exchange, or a newly issued token)
BFFOrder 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

ThreatCookie OnlyJWT Only (localStorage)Hybrid (BFF)
XSSSafe with HttpOnlyVery vulnerable (token theft)Safe with HttpOnly cookie
CSRFVulnerable (mitigated by SameSite)Safe (no cookies used)Defended with SameSite + CSRF token
Token TheftOnly session ID exposedBoth AT+RT exposure riskTokens exist only on server
Replay AttackSession bindingReusable if AT stolenShort-lived AT + server verification
Session FixationDefended by session regeneration after loginNot applicableDefended by session regeneration
Immediate token invalid.Possible by session deletionImpossible (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

CategorySpring BootDjangoReact (SPA)Next.js (App Router)
Token StorageServer session (HttpSession)Server session (DB/Redis)Memory variableServer session (NextAuth)
JWT Verifyspring-security-oauth2-resource-serverPyJWT / djangorestframework-simplejwtNot needed (with BFF pattern)jose library (server side)
MiddlewareSecurityFilterChainDjangoMiddlewareAxios interceptormiddleware.ts
CSRF DefenseCsrfFilter (automatic)CsrfViewMiddleware (automatic)Not needed without cookiesManual CSRF Token implementation
LogoutOidcClientInitiatedLogoutSuccessHandlermozilla-django-oidc logout viewMemory token deletionsignOut() + IdP logout
Session StoreRedis (Spring Session)Redis (django-redis)Not applicableRedis / DB

Integration Checklist

These are the items you must check when implementing SSO + hybrid authentication.


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

  1. OpenID Connect Core 1.0 Specification
  2. RFC 6749 - The OAuth 2.0 Authorization Framework
  3. RFC 6750 - OAuth 2.0 Bearer Token Usage
  4. RFC 7519 - JSON Web Token (JWT)
  5. RFC 7662 - OAuth 2.0 Token Introspection
  6. RFC 7636 - Proof Key for Code Exchange (PKCE)
  7. OpenID Connect RP-Initiated Logout
  8. OpenID Connect Back-Channel Logout
  9. Keycloak Documentation - Securing Applications
  10. Auth0 - Token Best Practices
  11. Auth0 - Refresh Token Rotation
  12. OWASP - Session Management Cheat Sheet
  13. OWASP - JSON Web Token Cheat Sheet
  14. Spring Security OAuth2 Resource Server Reference
  15. NextAuth.js Documentation
  16. Mozilla Django OIDC Documentation
  17. RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
  18. Istio Security - Request Authentication

Comments

No comments yet.

Sign in to leave a comment