Session Management with SSO
After SSO authenticates the user, your app needs to remember “this user is logged in” across requests. The question: do you use server-side sessions, stateless tokens, or some hybrid? Each has trade-offs.
The three models
| Model | What client sends | Server checks | Pros / cons |
|---|---|---|---|
| Server-side session | session ID (cookie) | Redis/DB lookup | revocable, server has control, cost: lookup per request |
| Stateless JWT | signed JWT (cookie or header) | verify signature | scales horizontally, cost: hard to revoke |
| Hybrid | session ID, but ID is opaque | lookup or local | best of both, more moving parts |
For most apps doing SSO: server-side session, set after SSO completes. You don’t need stateless tokens just because OIDC happens to use JWTs internally.
The standard pattern
After SSO success:
1. User completes OIDC/SAML flow at IdP
2. App receives ID token / SAML assertion
3. App validates the token/assertion
4. App creates a local session:
- Generate session ID
- Store session data (user_id, expiry, IdP info) in Redis/DB
- Set HttpOnly cookie with session ID
5. App redirects to original destination
6. Subsequent requests: cookie → lookup session → set request.user
The IdP’s tokens are used once to establish the local session. They’re not re-presented on every request.
@app.get("/callback")
def callback(code: str, state: str, request: Request, response: Response):
# 1. Validate state, exchange code for tokens
tokens = oauth_client.exchange_code(code)
claims = oauth_client.validate_id_token(tokens["id_token"])
# 2. Create local session
session_id = secrets.token_urlsafe(32)
redis.setex(f"sess:{session_id}", 3600, json.dumps({
"user_id": claims["sub"],
"email": claims["email"],
"issued_at": time.time(),
}))
# 3. Set cookie
response.set_cookie(
"session",
session_id,
httponly=True,
secure=True,
samesite="lax",
max_age=3600,
)
return RedirectResponse("/")
Each subsequent request:
@app.middleware("http")
async def session_middleware(request: Request, call_next):
sid = request.cookies.get("session")
if sid:
data = redis.get(f"sess:{sid}")
if data:
request.state.user = json.loads(data)
return await call_next(request)
Why not just re-validate the JWT every request?
You could:
@app.middleware("http")
async def jwt_middleware(request, call_next):
token = request.cookies.get("token")
if token:
claims = jwt.decode(token, jwks, audience=client_id)
request.state.user = claims
return await call_next(request)
Issues:
- Revocation is hard: the JWT is valid until
exp. Logout doesn’t make it stop working unless you maintain a blocklist (which defeats statelessness). - Updating user state is hard: role changed? Token still has old roles until expiry.
- Token rotation: you need to handle silent refresh in the background.
For SSO apps, the typical answer is “server session after auth” — JWT/SAML get the user in the door; the local session keeps them in.
Cookie attributes — get these right
response.set_cookie(
"session",
sid,
httponly=True, # JS can't read it — XSS protection
secure=True, # only sent over HTTPS
samesite="lax", # not sent on cross-site POST/iframe (CSRF protection)
max_age=3600, # cookie expires after 1 hour
domain="example.com", # set if needed for subdomain sharing
path="/",
)
| Attribute | Effect |
|---|---|
HttpOnly |
inaccessible to JS — protects against XSS |
Secure |
only sent over HTTPS — protects against MITM |
SameSite=Strict |
never sent on cross-site requests (breaks some flows) |
SameSite=Lax |
sent on top-level navigation, not on POST/iframe (recommended default) |
SameSite=None; Secure |
required for cross-origin (e.g. SaaS embedded in customer’s iframe) |
Max-Age / Expires |
cookie lifetime in the browser |
Domain |
which domains receive the cookie |
HttpOnly + Secure + SameSite=Lax is the standard baseline. Stronger: SameSite=Strict.
Session lifetime patterns
Two policies, often combined:
| Policy | Behavior |
|---|---|
| Absolute | session expires N hours after creation, no matter what |
| Sliding | session expires N minutes after last activity |
For convenience: sliding with an absolute cap.
def get_session(sid):
data = redis.get(f"sess:{sid}")
if not data:
return None
sess = json.loads(data)
if time.time() > sess["created_at"] + 86400: # absolute: 24h max
redis.delete(f"sess:{sid}")
return None
redis.expire(f"sess:{sid}", 3600) # sliding: 1h since last use
return sess
Banks: short absolute (15 min). Consumer SaaS: long absolute (24 hr) with sliding.
Session vs IdP session — two layers
Two sessions exist after SSO:
- App session (your cookie, your Redis).
- IdP session (the IdP’s cookie at
idp.example.com).
The IdP session is what makes SSO “single” — when User opens App B, redirect to IdP, IdP sees its session cookie, no re-login.
Implications:
- Logging out of your app does NOT log out of the IdP (need SLO).
- IdP can revoke its session (admin offboarding, password reset), but your app session keeps running until expiry. Need silent re-auth or push-based revocation.
Silent re-authentication
To avoid users re-logging-in mid-session:
1. Access token expires
2. Frontend sees 401 from API
3. Frontend silently calls /refresh (uses refresh token cookie)
4. Backend redeems refresh token for new access token
5. Frontend retries the original API call
Or for OIDC-specific:
1. App calls IdP /authorize with prompt=none (don't prompt user)
2. If IdP session still alive → returns code → new tokens
3. If IdP session ended → returns login_required error → full auth flow
The prompt=none flow lets you check “is the user still logged in at the IdP?” without UI.
Session fixation
After login, regenerate the session ID:
def login(user_id):
# discard pre-login session
if request.cookies.get("session"):
redis.delete(f"sess:{request.cookies['session']}")
# generate fresh session ID
sid = secrets.token_urlsafe(32)
redis.setex(f"sess:{sid}", 3600, json.dumps({"user_id": user_id}))
response.set_cookie("session", sid, httponly=True, secure=True)
Prevents an attacker from pre-setting a session ID, tricking the user into logging into it, then using the same ID.
CSRF — when sessions live in cookies
Cookie-based sessions are vulnerable to Cross-Site Request Forgery: an attacker’s site triggers a POST to yours; the browser sends the session cookie automatically.
Protections:
SameSite=LaxorStrict(modern baseline).- CSRF tokens on state-changing requests (the Django pattern).
- Re-authentication for sensitive actions (password change, money transfer).
See ../../25_security/03_xss_csrf.md.
Mobile and SPAs — different storage
| Client | Session pattern |
|---|---|
| Server-rendered web | HttpOnly cookie (the default) |
| SPA + backend on same domain | HttpOnly cookie, frontend uses fetch with credentials: 'include' |
| SPA on different origin | CORS + cookie with SameSite=None; Secure; or token in memory + refresh via cookie |
| Native mobile | tokens in secure storage (Keychain/Keystore), Authorization header |
| CLI / desktop | tokens in user-perm-restricted file |
Cross-origin SSO callbacks
If your SSO callback is at auth.example.com/callback but the app is at app.example.com:
- Cookie domain
.example.commakes the session cookie visible to both. - Use this pattern when you have multiple subdomains sharing identity.
Don’t set Domain=example.com if auth.example.com doesn’t need the cookie elsewhere — minimum exposure.
Logout
Local logout:
@app.post("/logout")
def logout(response: Response, sid: str = Depends(get_sid)):
redis.delete(f"sess:{sid}")
response.delete_cookie("session")
return RedirectResponse("/")
For SSO logout that also clears the IdP session, see 05_single_logout.md.
Common pitfalls
- Not regenerating session ID after login — session fixation.
SameSite=NonewithoutSecure— modern browsers reject the cookie silently.- Cookie domain too broad (
.example.com) — leaks to all subdomains. HttpOnlymissing — JS-accessible, XSS leaks the session.- No CSRF protection on state-changing endpoints — POST forgery possible.
- Session never expires — stolen sessions valid forever.
- Validating IdP tokens on every request instead of using a local session — slow and complicated.
- Storing the access token in the session — fine for short-lived; problematic if your session outlasts the access token. Keep them separate.
Common interview confusions
- “OIDC means JWTs everywhere, including session cookies.” — only inside the OIDC dance. Your app’s session can (and usually should) be a server-side session keyed by an opaque cookie.
- “Stateless JWT is always better.” — it’s better for scaling (no Redis lookup), worse for revocation. Most teams find server sessions simpler.
- “
SameSite=Strictis too restrictive.” — for many apps it works fine.Laxis the common compromise.
Interview angle
- “After SSO completes, how does your app remember the user?” — create a server-side session (Redis, key = random session ID, value = user_id + expiry), set an HttpOnly cookie with that session ID. Subsequent requests look up Redis.
- “Why not just use the ID token as the session?” — JWTs are hard to revoke. Server session lets you log users out, update roles immediately, and track active sessions for monitoring.
- “What cookie attributes do you set on session cookies?” — HttpOnly (no JS access), Secure (HTTPS only), SameSite=Lax (CSRF mitigation), Max-Age, optionally Domain.
- “What’s session fixation?” — attacker pre-sets a session ID, tricks user into logging into it, then uses the same ID. Mitigate by regenerating the session ID on login.
- “Sliding vs absolute session expiry?” — sliding extends on activity (better UX); absolute caps total lifetime regardless of activity (better security). Combine: sliding 1 hr with absolute 24 hr cap.
- “How do you do silent re-auth when an access token expires?” — frontend gets 401, calls
/refresh(uses HttpOnly RT cookie), backend redeems RT for new access token, frontend retries. Or use OIDCprompt=noneto check IdP session is still alive. - “What happens to the IdP session when you log out of your app?” — nothing automatically. Your app’s local session is cleared. For “log out everywhere,” use OIDC end_session_endpoint or SAML SLO (see 05_single_logout.md).