Backend / Authentication / SSO / 04_session_management.md

Session Management with SSO

Updated 7 interview angles 7 min read source
On this page15
  1. The three models
  2. The standard pattern
  3. Why not just re-validate the JWT every request?
  4. Cookie attributes — get these right
  5. Session lifetime patterns
  6. Session vs IdP session — two layers
  7. Silent re-authentication
  8. Session fixation
  9. CSRF — when sessions live in cookies
  10. Mobile and SPAs — different storage
  11. Cross-origin SSO callbacks
  12. Logout
  13. Common pitfalls
  14. Common interview confusions
  15. Interview angle

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:

text
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.

python
@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:

python
@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:

python
@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.

python
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.

python
def get_session(sid):
    data = redis.get(f"sess:{sid}")
    if not data:
        return None
    sess = json.loads(data)
    # absolute: 24h max
    if time.time() > sess["created_at"] + 86400:
        redis.delete(f"sess:{sid}")
        return None
    # sliding: 1h since last use
    redis.expire(f"sess:{sid}", 3600)
    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:

  1. App session (your cookie, your Redis).
  2. 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:

text
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:

text
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:

python
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=Lax or Strict (modern baseline).
  • CSRF tokens on state-changing requests (the Django pattern).
  • Re-authentication for sensitive actions (password change, money transfer).

See XSS and CSRF.

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.com makes 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:

python
@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 Single Logout (SLO).

Common pitfalls

  • Not regenerating session ID after login — session fixation.
  • SameSite=None without Secure — modern browsers reject the cookie silently.
  • Cookie domain too broad (.example.com) — leaks to all subdomains.
  • HttpOnly missing — 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=Strict is too restrictive.” — for many apps it works fine. Lax is the common compromise.

Interview angle 7

  • “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 OIDC prompt=none to 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 Single Logout (SLO)).