backend / authentication / sso / 04_session_management.md

Session Management with SSO

7 interview angles 7 min read source

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.

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:

  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:

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=Lax or Strict (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.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:

@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=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

  • “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 05_single_logout.md).