61 KiB
Dex Enhancement Proposal (DEP 4560) - 2026-02-18 - Auth Sessions
Table of Contents
Summary
This DEP introduces auth sessions - a persistent authentication state that enables Dex to track logged-in users across browser sessions. Currently, Dex relies entirely on refresh tokens for session management, which prevents proper implementation of OIDC conformance features like prompt=none, prompt=login, id_token_hint, SSO across clients, and proper logout. User Sessions will be stored server-side with a browser cookie reference, enabling these features while maintaining Dex's simplicity and compatibility with all storage backends (SQL, etcd, Kubernetes CRDs).
Context
- OIDC Core 1.0 - Authentication Request -
promptparameter specification - OIDC Core 1.0 - ID Token Hint -
id_token_hintspecification - OIDC Session Management 1.0 - Session management specification
- OIDC RP-Initiated Logout 1.0 - Logout specification
- OIDC Front-Channel Logout 1.0 - Front-channel logout
- Keycloak Sessions - Reference implementation
- Ory Hydra Login & Consent Flow - Reference implementation
Current limitations:
- No support for
prompt=none(silent authentication) - No support for
prompt=login(force re-authentication) - No support for
max_ageparameter - No support for
id_token_hintvalidation - No SSO between clients (each client requires separate login)
- No proper logout (only refresh token revocation)
- No consent persistence (user must approve every time if not skipped globally)
- No 2FA enrollment storage
- No "Remember Me" functionality
Motivation
Goals/Pain
- OIDC Conformance - Enable proper
prompt=none,prompt=login,max_age, andid_token_hintsupport - SSO (Single Sign-On) - Allow users to authenticate once and access multiple clients without re-login
- Remember Me - Allow users to choose persistent vs session-based authentication
- Consent Persistence - Store user consent decisions per client/scope combination within session
- Proper Logout - Enable session termination with optional front-channel logout
- Foundation for 2FA - Enable future TOTP/WebAuthn enrollment storage
Non-Goals
- 2FA Implementation - This DEP only provides storage foundation; 2FA flow is a separate DEP
- Back-Channel Logout - Server-to-server logout notifications are out of scope
- Session Clustering/Replication - Storage backends handle this
- Admin Session Management UI - API only, no admin UI
- Per-connector Session Policies - Single global session policy initially
- Identity Refresh During Session - Deferred to future DEP; initially identity is refreshed only at session termination (like Keycloak)
- Upstream Connector Logout - Terminating sessions at upstream IDPs is deferred
Proposal
User Experience
Configuration
Sessions are controlled by a feature flag and configuration:
# Feature flag (environment variable)
# DEX_SESSIONS_ENABLED=true
# config.yaml
sessions:
# Session cookie name (default: "dex_session")
# Other cookie settings (Secure, HttpOnly, SameSite=Lax) are not configurable
# and are set to secure defaults automatically
cookieName: "dex_session"
# Session lifetime settings (matches refresh token expiry naming)
absoluteLifetime: "24h" # Maximum session lifetime, default: 24h
validIfNotUsedFor: "1h" # Session expires if not used, default: 1h
# Default SSO sharing policy for clients without explicit ssoSharedWith config
# Options:
# "all" - clients without ssoSharedWith share sessions with all other clients (Keycloak-like)
# "none" - clients without ssoSharedWith don't share sessions (default)
ssoSharedWithDefault: "none"
# Whether "Remember Me" checkbox is checked by default in login/approval forms
# When true: checkbox is pre-checked, user can uncheck
# When false: checkbox is unchecked, user must check to persist session (default)
rememberMeCheckedByDefault: false
ssoSharedWithDefault controls the default SSO behavior:
"none"(default): Clients without explicitssoSharedWithconfig don't participate in SSO"all": Clients without explicitssoSharedWithconfig share sessions with all other clients (realm-wide SSO like Keycloak)
Clients with explicit ssoSharedWith configuration always use their configured value.
Note: The ssoSharedWith option is separate from the existing trustedPeers option. trustedPeers controls which clients can issue tokens on behalf of this client (existing behavior), while ssoSharedWith controls which clients can reuse this client's authentication session (new behavior). These can be configured independently based on different security requirements.
rememberMeCheckedByDefault controls the initial checkbox state in templates.
This value is passed to templates as .RememberMeChecked boolean.
SSO via ssoSharedWith: SSO between clients is controlled by the new ssoSharedWith configuration on clients. The ssoSharedWith setting defines which clients can USE this client's session, not which clients this client can use.
If client B is listed in client A's ssoSharedWith:
- If user logged in via client A, client B can reuse that session
This is intentionally separate from trustedPeers (which controls token issuance on behalf of another client). Organizations may want different policies for session sharing vs token delegation:
- ssoSharedWith: "Can this client's login be reused by another client?"
- trustedPeers: "Can another client issue tokens claiming to be this client?"
Wildcard Support: ssoSharedWith: ["*"] enables SSO with all clients. This is similar to Keycloak's default behavior where all clients in a realm share sessions.
SSO Direction: SSO sharing is unidirectional. Client A sharing with client B does NOT mean client B shares with client A.
staticClients:
# Public app - allows any client to reuse its sessions
- id: public-app
name: Public App
ssoSharedWith: ["*"]
# trustedPeers can be configured separately for token delegation
# ...
# Admin app - only specific apps can reuse its sessions
- id: admin-app
name: Admin App
ssoSharedWith: ["monitoring-app"] # Only monitoring can SSO from admin sessions
# ...
# Secret internal service - NO other clients can reuse its sessions
- id: secret-service
name: Secret Service
ssoSharedWith: [] # Empty = no SSO allowed from this client's sessions
# But this client CAN use sessions from other clients that share with it!
# ...
# Monitoring app - can SSO from admin-app (because admin-app shares with it)
- id: monitoring-app
name: Monitoring App
ssoSharedWith: ["admin-app"] # Bidirectional sharing with admin-app
# ...
Example Scenarios:
| User logged in via | Accessing | SSO works? | Why |
|---|---|---|---|
| public-app | admin-app | ✅ Yes | public-app has ssoSharedWith: ["*"] |
| admin-app | public-app | ❌ No | admin-app only shares with monitoring-app |
| admin-app | monitoring-app | ✅ Yes | admin-app shares with monitoring-app |
| secret-service | any client | ❌ No | secret-service has ssoSharedWith: [] |
| public-app | secret-service | ✅ Yes | public-app has ssoSharedWith: ["*"] |
Key Insight: A "secret" client that doesn't want others to SSO into it simply doesn't list them in ssoSharedWith. But it can still BENEFIT from SSO by being listed in OTHER clients' ssoSharedWith.
Comparison with Keycloak: In Keycloak, SSO is realm-wide by default - all clients in a realm share sessions. Dex's approach is more granular: SSO is opt-in per client via ssoSharedWith. Use ["*"] to achieve Keycloak-like behavior.
Comparison with trustedPeers: The trustedPeers option continues to control cross-client token issuance (e.g., client B issuing tokens for client A). This is a separate security concern from session sharing. Organizations can configure these independently:
- High SSO sharing, restricted token delegation
- Restricted SSO sharing, high token delegation
- Or any combination based on their security model
Cookie Security: The session cookie is always set with secure defaults:
HttpOnly: true- Not accessible via JavaScriptSecure: (issuerURL.Scheme == "https")- Only sent over HTTPS; forhttp(commonly used on localhost in dev) this is disabledSameSite: Lax- CSRF protectionPath: <issuerURL.Path>- Derived from issuer URL (e.g.,/dexforhttps://example.com/dex)
These settings are not configurable to prevent security misconfigurations.
Authentication Flow with Sessions
┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │ │ Connector │
└────┬────┘ └────┬────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │
│ GET /auth │ │ │
│ (no session) │ │ │
├────────────────>│ │ │
│ │ │ │
│ │ Check session │ │
│ │ cookie │ │
│ ├─────────────────>│ │
│ │ (not found) │ │
│ │<─────────────────│ │
│ │ │ │
│ Redirect to │ │ │
│ connector │ │ │
│<────────────────│ │ │
│ │ │ │
│ ... connector auth flow ... │ │
│ │ │ │
│ Callback with │ │ │
│ identity │ │ │
├────────────────>│ │ │
│ │ │ │
│ │ Create/update │ │
│ │ AuthSession │ │
│ │ (ALWAYS) │ │
│ ├─────────────────>│ │
│ │ │ │
│ Set-Cookie: │ │ │
│ - Session cookie (no MaxAge) │ │
│ if Remember Me unchecked │ │
│ - Persistent cookie (with MaxAge) │ │
│ if Remember Me checked │ │
│ + redirect to /approval │ │
│<────────────────│ │ │
│ │ │ │
Key Point: AuthSession is always created on successful authentication. The "Remember Me" checkbox only controls whether the cookie is a session cookie (deleted on browser close) or a persistent cookie (survives browser restart). This is consistent with Keycloak's behavior.
SSO Flow (Returning User)
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ GET /auth │ │
│ (with cookie) │ │
│ client_id=B │ │
├────────────────>│ │
│ │ │
│ │ Get session │
│ ├─────────────────>│
│ │ (valid session) │
│ │<─────────────────│
│ │ │
│ │ Check SSO │
│ │ policy for │
│ │ client B │
│ │ │
│ │ Check consent │
│ │ for client B │
│ ├─────────────────>│
│ │ │
│ If consented: │ │
│ redirect with │ │
│ code │ │
│<────────────────│ │
│ │ │
│ If not: │ │
│ show approval │ │
│<────────────────│ │
│ │ │
prompt=none Flow
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ GET /auth │ │
│ prompt=none │ │
├────────────────>│ │
│ │ │
│ │ Get session │
│ ├─────────────────>│
│ │ │
│ If valid session + consent: │
│ redirect with code │
│<────────────────│ │
│ │ │
│ If no session or no consent: │
│ redirect with error=login_required│
│ or error=consent_required │
│<────────────────│ │
│ │ │
Logout Flow
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ GET /logout │ │
│ id_token_hint= │ │
├────────────────>│ │
│ │ │
│ │ Validate │
│ │ id_token_hint │
│ │ │
│ │ Get identity │
│ │ by session ID │
│ ├─────────────────>│
│ │ │
│ │ Deactivate │
│ │ (Active=false) │
│ ├─────────────────>│
│ │ │
│ │ Revoke refresh │
│ │ tokens │
│ ├─────────────────>│
│ │ │
│ Clear cookie + │ │
│ redirect or │ │
│ show logout │ │
│ confirmation │ │
│<────────────────│ │
│ │ │
Implementation Details/Notes/Constraints
Feature Flag
// pkg/featureflags/set.go
var (
// ...existing flags...
// SessionsEnabled enables user sessions feature
SessionsEnabled = newFlag("sessions_enabled", false)
)
New Storage Entities
Two entities are required to properly handle the case where a user might be logged into different clients as different identities in the same browser:
AuthSession
// storage/storage.go
// AuthSession represents a browser's authentication state.
// One per browser, referenced by session cookie.
// Key: SessionID (random 32-byte string, stored in cookie)
type AuthSession struct {
// ID is the session identifier stored in cookie
ID string
// ClientStates maps clientID → authentication state for that client
// Allows different users/identities per client in same browser
//
// Design note: This map-based approach is consistent with how OfflineSessions
// stores refresh tokens per client (OfflineSessions.Refresh map). Given that
// the number of OAuth clients in a typical deployment is bounded and relatively
// small (tens to hundreds, not thousands), the serialized size of this map
// will not exceed practical storage limits for any supported backend.
ClientStates map[string]*ClientAuthState
// CreatedAt is when this browser session started
CreatedAt time.Time
// LastActivity is when any client was last accessed
LastActivity time.Time
// IPAddress at session creation (for audit)
IPAddress string
// UserAgent at session creation (for audit)
UserAgent string
}
// ClientAuthState represents authentication state for a specific client within an auth session.
// Expiration follows OIDC conventions with both absolute and idle timeout:
// - ExpiresAt enforces absolute lifetime (sessions.absoluteLifetime)
// - LastActivity + sessions.validIfNotUsedFor enforces idle timeout
// A client state is considered expired if EITHER condition is met.
type ClientAuthState struct {
// UserID + ConnectorID identify which UserIdentity is authenticated for this client
UserID string
ConnectorID string
// Active indicates if authentication is active for this client
Active bool
// ExpiresAt is the absolute expiration time for this client session.
// Set to time.Now() + absoluteLifetime at session creation.
// Cannot be extended - hard upper bound on session duration.
ExpiresAt time.Time
// LastActivity is when this client session was last used (token issued, SSO check, etc.)
// Used with validIfNotUsedFor to enforce idle timeout.
// Updated on each request that touches this client state.
LastActivity time.Time
// LastTokenIssuedAt is when a token was last issued for this client.
// Used for logout notifications and audit.
LastTokenIssuedAt time.Time
}
UserIdentity
// storage/storage.go
// UserIdentity represents a user's persistent identity data.
// Stores data that persists across sessions:
// - Consent decisions
// - Future: 2FA enrollment
//
// Key: composite of UserID + ConnectorID (one per user per connector)
type UserIdentity struct {
// UserID is the subject identifier from the connector
UserID string
// ConnectorID is the connector that authenticated the user
ConnectorID string
// Claims holds the user's identity claims
// Updated on:
// 1. Each login (from connector callback)
// 2. Each refresh token usage (from RefreshConnector.Refresh)
// This ensures claims stay in sync with OfflineSessions and upstream IDP
Claims Claims
// Consents stores user consent per client: map[clientID][]scopes
// Persists across sessions so user doesn't need to re-consent
Consents map[string][]string
// CreatedAt is when this identity was first created
CreatedAt time.Time
// LastLogin is when the user last authenticated (used for auth_time claim)
LastLogin time.Time
// BlockedUntil is set when user is blocked from logging in
BlockedUntil time.Time
// Future: 2FA fields
// TOTPSecret string
// WebAuthnCredentials []WebAuthnCredential
}
Two-Entity Design Rationale
| Entity | Purpose | Lifecycle | Key |
|---|---|---|---|
| AuthSession | Browser binding, per-client auth state | Short-lived (session timeout) | SessionID (cookie) |
| UserIdentity | User data, consents, 2FA | Long-lived (persists) | UserID + ConnectorID |
How It Works: Different Users in Different Clients
Auth Session (cookie: dex_session=abc123)
├── ClientStates["client-A"]:
│ └── UserID: "alice", ConnectorID: "google", Active: true
├── ClientStates["client-B"]:
│ └── UserID: "bob", ConnectorID: "ldap", Active: true
└── ClientStates["client-C"]:
└── (empty - never authenticated)
UserIdentity (alice + google):
├── Claims: {email: alice@example.com, ...}
├── Consents: {"client-A": ["openid", "email"]}
└── LastLogin: 2024-01-01
UserIdentity (bob + ldap):
├── Claims: {email: bob@corp.com, ...}
├── Consents: {"client-B": ["openid", "groups"]}
└── LastLogin: 2024-01-02
How SSO Works
When user accesses client-B with existing session:
- Get
AuthSessionby cookie - Check
ClientStates["client-B"]:- If exists and active → user already authenticated for this client
- If not, check SSO:
- Find any
ClientStates[X]where client-X hasssoSharedWithcontaining "client-B" - If found → SSO! Copy auth state to
ClientStates["client-B"] - If not found → require authentication
- Find any
SSO Session Lookup Algorithm
// findSSOSession searches for a valid SSO source session for the target client
func (s *Server) findSSOSession(authSession *AuthSession, targetClientID string) (*ClientAuthState, *UserIdentity) {
targetClient, err := s.storage.GetClient(ctx, targetClientID)
if err != nil {
return nil, nil
}
// Iterate through all active client states in this browser session
for sourceClientID, state := range authSession.ClientStates {
// Skip inactive or expired states
if !state.Active || time.Now().After(state.ExpiresAt) {
continue
}
// Get the source client configuration
sourceClient, err := s.storage.GetClient(ctx, sourceClientID)
if err != nil {
continue
}
// Check if source client shares its session with the target client
// SSO is allowed if:
// 1. Source client has ssoSharedWith: ["*"] (shares with everyone)
// 2. Source client has targetClientID in its ssoSharedWith list
if !s.clientSharesSessionWith(sourceClient, targetClientID) {
continue
}
// Found a valid SSO source! Get the user identity
identity, err := s.storage.GetUserIdentity(ctx, state.UserID, state.ConnectorID)
if err != nil {
continue
}
// Check if user is not blocked
if identity.BlockedUntil.After(time.Now()) {
continue
}
return state, identity
}
return nil, nil
}
// clientSharesSessionWith checks if sourceClient shares its session with targetClientID
func (s *Server) clientSharesSessionWith(sourceClient Client, targetClientID string) bool {
for _, peer := range sourceClient.SSOSharedWith {
if peer == "*" || peer == targetClientID {
return true
}
}
return false
}
SSO Lookup Flow Diagram
User accesses client-B with existing session
│
▼
┌─────────────────────────────────┐
│ Get AuthSession from cookie │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Check ClientStates["client-B"] │
│ exists and active? │
└─────────────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌──────────────┐ ┌─────────────────────────────────┐
│ Use existing │ │ For each ClientStates[X]: │
│ session │ │ - Is state active? │
└──────────────┘ │ - Get client-X config │
│ - Does client-X share with B? │
│ (X.ssoSharedWith has B or *)│
└─────────────────────────────────┘
│ │
Found match No match
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SSO! Copy │ │ Require │
│ state to B │ │ authentication│
└──────────────┘ └──────────────┘
Example: SSO Flow
1. User logs into client-A as alice
AuthSession.ClientStates["client-A"] = {UserID: "alice", Active: true}
2. User accesses client-B
- client-A.ssoSharedWith includes "client-B" ✓
- SSO! Copy: ClientStates["client-B"] = {UserID: "alice", Active: true}
- Issue tokens for alice to client-B
Example: No SSO, Different User
1. User logged into client-A as alice
AuthSession.ClientStates["client-A"] = {UserID: "alice", Active: true}
2. User accesses client-B (client-A does NOT share with client-B)
- No SSO available
- Redirect to connector for authentication
3. User logs in as bob (different account)
AuthSession.ClientStates["client-B"] = {UserID: "bob", Active: true}
Same browser, two different users, no conflict!
Claims Synchronization with Refresh Tokens
When a refresh token is used:
RefreshConnector.Refresh()returns updated claims- Update
OfflineSessions.ConnectorData(existing behavior) - NEW: Also update
UserIdentity.Claims:
// In refresh token handler
func (s *Server) handleRefreshToken(...) {
// ...existing refresh logic...
newIdentity, err := refreshConn.Refresh(ctx, scopes, oldIdentity)
if err != nil {
// Handle refresh failure
}
// Update OfflineSessions (existing)
s.storage.UpdateOfflineSessions(...)
// Update UserIdentity claims (NEW)
if s.sessionsEnabled {
s.storage.UpdateUserIdentity(ctx, newIdentity.UserID, connectorID,
func(u UserIdentity) (UserIdentity, error) {
u.Claims = storage.Claims{
UserID: newIdentity.UserID,
Username: newIdentity.Username,
Email: newIdentity.Email,
Groups: newIdentity.Groups,
// ...
}
return u, nil
})
}
}
This ensures UserIdentity.Claims stays synchronized with:
- Connector's current user data
OfflineSessions.ConnectorData- Actual refresh token claims
Why UserIdentity instead of AuthSession?
The name UserIdentity is chosen because this entity stores more than just session state:
- Persistent data: Consent decisions survive session expiration
- Future 2FA: TOTP secrets and WebAuthn credentials will be stored here
- One per user/connector: Unlike sessions which could be per-browser, this is per-identity
Session ID Regeneration
The AuthSession.ID is regenerated when:
- User logs in from a new browser (new session created)
- Security concern requires new session (e.g., after password change)
Individual ClientStates can be invalidated without changing the auth session ID.
Multiple Users in Same Browser
With the two-entity design:
AuthSessiontracks which user is authenticated for which client- Different clients can have different users (if no SSO trust)
- Same user can be authenticated for multiple clients (SSO or separate logins)
SSO and Different Users
With SSO enabled between clients, the same user is used for all sharing clients:
- User logs in to client-A as "alice@example.com"
- User accesses client-B (client-A shares with client-B) → automatically authenticated as "alice@example.com"
- SSO reuses the identity from the sharing client
If user needs to login as different identity to a sharing client:
- Use
prompt=loginto force re-authentication - This creates new ClientState for that client with potentially different user
Without SSO, user can be different identities in different clients (see examples above).
Storage Interface Extensions
Two new entities require CRUD operations:
// storage/storage.go
type Storage interface {
// ...existing methods...
// AuthSession management
CreateAuthSession(ctx context.Context, s AuthSession) error
GetAuthSession(ctx context.Context, sessionID string) (AuthSession, error)
UpdateAuthSession(ctx context.Context, sessionID string, updater func(s AuthSession) (AuthSession, error)) error
DeleteAuthSession(ctx context.Context, sessionID string) error
// UserIdentity management
CreateUserIdentity(ctx context.Context, u UserIdentity) error
GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error)
UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error
DeleteUserIdentity(ctx context.Context, userID, connectorID string) error
// List for admin API
ListUserIdentities(ctx context.Context) ([]UserIdentity, error)
}
Garbage Collection
type GCResult struct {
// ...existing fields...
AuthSessions int64 // NEW: expired auth sessions cleaned up
}
AuthSession objects are garbage collected when:
LastActivity + validIfNotUsedForexceeded (inactivity)- All
ClientStateshave expired
UserIdentity objects are NOT garbage collected (preserve consents, future 2FA).
Session Expiration
AuthSession expiration:
- Entire session expires when
LastActivity + validIfNotUsedForis reached (idle timeout) - On expiration,
AuthSessionis deleted by GC - User must re-authenticate for all clients
ClientAuthState expiration (per-client within AuthSession):
Each client state enforces both absolute lifetime and idle timeout, consistent with standard OIDC session semantics:
func (s *Server) isClientStateValid(state *ClientAuthState) bool {
now := time.Now()
// 1. Check absolute lifetime - hard upper bound, cannot be extended
if now.After(state.ExpiresAt) {
return false
}
// 2. Check idle timeout - session unused for too long
if now.After(state.LastActivity.Add(s.sessionsConfig.validIfNotUsedFor)) {
return false
}
// 3. Check explicit deactivation (admin revoked)
if !state.Active {
return false
}
return true
}
When a client state expires:
- Other clients in same auth session remain active
- User must re-authenticate only for the expired client
- On successful re-authentication, a new
ClientAuthStateis created with freshExpiresAt
Admin can force re-authentication:
- Delete
AuthSession→ user must re-auth for all clients - Set
ClientStates[clientID].Active = false→ user must re-auth for that client only
Deletion Risks
Deleting AuthSession:
- User must re-authenticate for all clients
- No data loss (consents preserved in UserIdentity)
- Safe operation for logout
Deleting UserIdentity:
| What's Lost | Impact |
|---|---|
| Consent decisions | User must re-approve scopes for all clients |
| Future: 2FA enrollment | User must re-enroll TOTP/WebAuthn |
When to delete UserIdentity:
- User explicitly requests account deletion (GDPR)
- Admin cleanup of stale identities
- User removed from upstream identity provider
When NOT to delete (delete AuthSession instead):
- Regular logout - delete AuthSession or set ClientState.Active = false
- Session expiration - GC handles AuthSession cleanup
- Security concern - delete AuthSession to force re-auth
Session Cookie Format
The session cookie contains only the session ID (not the session data):
Cookie: dex_session=<session_id>; Path=<issuer_path>; Secure; HttpOnly; SameSite=Lax
Cookie Path: Derived from the issuer URL path (issuerURL.Path). For example:
- Issuer:
https://dex.example.com/→Path=/ - Issuer:
https://example.com/dex→Path=/dex
This is consistent with how Dex already handles routing - all endpoints are prefixed with the issuer path.
Session Creation vs Cookie Persistence (Keycloak-like behavior)
Unlike some implementations where "Remember Me" controls session creation, we follow Keycloak's approach:
- AuthSession is ALWAYS created on successful authentication
- "Remember Me" controls cookie persistence:
- Unchecked: Session cookie (expires when browser closes)
- Checked: Persistent cookie (expires at
absoluteLifetime)
This approach is better because:
- SSO works within a browser session even without "Remember Me"
- Consent decisions are preserved during the browser session
prompt=noneworks correctly within browser session- More intuitive: "Remember Me" = "remember me after I close the browser"
func (s *Server) setSessionCookie(w http.ResponseWriter, sessionID string, rememberMe bool) {
cookie := &http.Cookie{
Name: s.sessionsConfig.CookieName,
Value: sessionID,
Path: s.issuerURL.Path,
HttpOnly: true,
Secure: s.issuerURL.Scheme == "https",
SameSite: http.SameSiteLaxMode,
}
if rememberMe {
// Persistent cookie - survives browser restart
cookie.MaxAge = int(s.sessionsConfig.absoluteLifetime.Seconds())
}
// else: Session cookie - no MaxAge, browser deletes on close
http.SetCookie(w, cookie)
}
Session ID generation:
func NewSessionID() string {
return newSecureID(32) // 256-bit random value
}
Client Configuration Extension
A new client configuration field is introduced for SSO control:
// storage/storage.go
type Client struct {
// ...existing fields...
// TrustedPeers are a list of peers which can issue tokens on this client's behalf.
// This is used for cross-client token issuance (existing behavior).
TrustedPeers []string `json:"trustedPeers" yaml:"trustedPeers"`
// SSOSharedWith defines which other clients can reuse this client's authentication session.
// When a user is authenticated for this client, clients listed here can skip authentication.
// This is separate from TrustedPeers - organizations may want different policies for
// session sharing vs token delegation.
// Special value "*" means share with all clients (Keycloak-like realm-wide SSO).
// nil means use ssoSharedWithDefault from sessions config.
// Empty slice [] means explicitly share with no one.
SSOSharedWith []string `json:"ssoSharedWith,omitempty" yaml:"ssoSharedWith,omitempty"`
}
Connector Logout (Future)
Logout URLs should be configured on connectors, not clients. A new connector interface will be added:
// connector/connector.go
// LogoutConnector is an optional interface for connectors that support
// terminating upstream sessions on logout.
type LogoutConnector interface {
// Logout terminates the user's session at the upstream identity provider.
// Returns a URL to redirect the user to for upstream logout, or empty string
// if no redirect is needed.
Logout(ctx context.Context, connectorData []byte) (logoutURL string, err error)
}
Connectors that implement this interface (e.g., OIDC with end_session_endpoint, SAML with SLO):
- Are called during Dex logout flow
- Can redirect user to upstream for complete logout
- Implementation details are connector-specific
This is tracked as a future improvement.
Server Configuration Extension
// cmd/dex/config.go
type Sessions struct {
// CookieName is the session cookie name (default: "dex_session")
CookieName string `json:"cookieName"`
// AbsoluteLifetime is the maximum session lifetime (default: "24h")
AbsoluteLifetime string `json:"absoluteLifetime"`
// ValidIfNotUsedFor is the inactivity timeout (default: "1h")
ValidIfNotUsedFor string `json:"validIfNotUsedFor"`
// SSOSharedWithDefault is the default SSO sharing policy
// "all" = share with all clients, "none" = share with no one (default: "none")
SSOSharedWithDefault string `json:"ssoSharedWithDefault"`
// RememberMeCheckedByDefault controls the initial checkbox state in templates
// true = pre-checked, false = unchecked (default: false)
RememberMeCheckedByDefault bool `json:"rememberMeCheckedByDefault"`
}
Using ssoSharedWithDefault in SSO logic:
func (s *Server) clientSharesSessionWith(sourceClient Client, targetClientID string) bool {
ssoSharedWith := sourceClient.SSOSharedWith
// If client has no explicit ssoSharedWith, use default
if ssoSharedWith == nil {
switch s.sessionsConfig.SSOSharedWithDefault {
case "all":
return true // Share with everyone by default
default: // "none"
return false // Share with no one by default
}
}
// Explicit configuration: empty slice means explicitly share with no one
// This is different from nil (not configured)
if len(ssoSharedWith) == 0 {
return false
}
// Check explicit sharing list
for _, peer := range ssoSharedWith {
if peer == "*" || peer == targetClientID {
return true
}
}
return false
}
Three states for ssoSharedWith:
nil(not configured) → usessoSharedWithDefault[](empty slice) → explicitly share with no one["client-a", ...]or["*"]→ explicit sharing list
Prompt Parameter Handling
Dex will support the following prompt values per OIDC Core specification:
none- Silent authentication, no UI displayedlogin- Force re-authenticationconsent- Force consent screen- Empty (default) - Normal flow with session reuse
The select_account value is not supported initially (would require account linking feature).
func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
// ...existing parsing...
prompt := r.Form.Get("prompt")
maxAge := r.Form.Get("max_age")
idTokenHint := r.Form.Get("id_token_hint")
clientID := r.Form.Get("client_id")
// Get auth session from cookie
authSession, err := s.getAuthSessionFromCookie(r)
// Get client auth state for this specific client
var clientState *ClientAuthState
var userIdentity *UserIdentity
if authSession != nil {
clientState = authSession.ClientStates[clientID]
if clientState != nil && clientState.Active {
userIdentity, _ = s.storage.GetUserIdentity(ctx, clientState.UserID, clientState.ConnectorID)
}
}
// Handle max_age parameter (OIDC Core 3.1.2.1)
if maxAge != "" && userIdentity != nil {
maxAgeSeconds, err := strconv.Atoi(maxAge)
if err == nil && maxAgeSeconds >= 0 {
authAge := time.Since(userIdentity.LastLogin)
if authAge > time.Duration(maxAgeSeconds)*time.Second {
// Session is too old, force re-authentication
clientState = nil
userIdentity = nil
}
}
}
switch prompt {
case "none":
// Silent authentication - must have valid session and consent
if clientState == nil || userIdentity == nil {
s.authErr(w, r, redirectURI, "login_required", state)
return
}
// Check consent in identity
consentedScopes, hasConsent := userIdentity.Consents[clientID]
if !hasConsent || !s.scopesCovered(consentedScopes, requestedScopes) {
s.authErr(w, r, redirectURI, "consent_required", state)
return
}
// Issue tokens without UI
case "login":
// Force re-authentication - ignore existing session for this client
clientState = nil
userIdentity = nil
// Continue to connector login
case "consent":
// Force consent screen even if previously consented
// Continue but don't check consent
default: // "" - normal flow
// Check for SSO from trusted clients if no direct session
if clientState == nil && authSession != nil {
clientState, userIdentity = s.findSSOSession(authSession, clientID)
}
}
// Validate id_token_hint if provided
if idTokenHint != "" {
claims, err := s.validateIDTokenHint(idTokenHint)
if err != nil {
s.authErr(w, r, redirectURI, "invalid_request", state)
return
}
if userIdentity != nil && userIdentity.UserID != claims.Subject {
// Identity user doesn't match hint
if prompt == "none" {
s.authErr(w, r, redirectURI, "login_required", state)
return
}
// Force re-login for different user
clientState = nil
userIdentity = nil
}
}
// ...continue with flow...
}
// findSSOSession looks for a valid SSO session from a sharing client
func (s *Server) findSSOSession(authSession *AuthSession, targetClientID string) (*ClientAuthState, *UserIdentity) {
for sourceClientID, state := range authSession.ClientStates {
if !state.Active {
continue
}
sourceClient, _ := s.storage.GetClient(ctx, sourceClientID)
if sourceClient == nil {
continue
}
// Check if source client shares its session with target client
if s.clientSharesSessionWith(sourceClient, targetClientID) {
identity, _ := s.storage.GetUserIdentity(ctx, state.UserID, state.ConnectorID)
if identity != nil {
return state, identity
}
}
}
return nil, nil
}
max_age Parameter
The max_age parameter is supported per OIDC Core specification:
- Specifies the maximum authentication age in seconds
- If the identity's last authentication time (
LastLogin) exceedsmax_age, force re-authentication - When
max_ageis used, theauth_timeclaim MUST be included in the ID token
New Endpoints
POST /logout
GET /logout
Logout endpoint following the OpenID RP-Initiated Logout specification (OpenID spec):
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
idTokenHint := r.FormValue("id_token_hint")
postLogoutRedirectURI := r.FormValue("post_logout_redirect_uri")
state := r.FormValue("state")
clientID := r.FormValue("client_id") // Optional: logout from specific client
// Get auth session from cookie
authSession, _ := s.getAuthSessionFromCookie(r)
// Validate id_token_hint if provided
var hintUserID, hintConnectorID string
if idTokenHint != "" {
claims, err := s.validateIDTokenHint(idTokenHint)
if err == nil {
hintUserID = claims.Subject
// Extract connector from token if possible
}
}
if authSession != nil {
if clientID != "" {
// Logout from specific client only
delete(authSession.ClientStates, clientID)
s.storage.UpdateAuthSession(ctx, authSession.ID, ...)
} else {
// Logout from all clients - delete entire auth session
s.storage.DeleteAuthSession(ctx, authSession.ID)
}
// Revoke refresh tokens for logged-out clients
// ...
}
// Clear cookie and redirect
s.clearSessionCookie(w)
// Show logout confirmation or redirect
if postLogoutRedirectURI != "" && s.isValidPostLogoutURI(postLogoutRedirectURI, idTokenHint) {
u, _ := url.Parse(postLogoutRedirectURI)
if state != "" {
q := u.Query()
q.Set("state", state)
u.RawQuery = q.Encode()
}
http.Redirect(w, r, u.String(), http.StatusFound)
return
}
// Show logout confirmation page
s.templates.logout(w, r)
}
Future: Upstream Connector Logout
For CallbackConnectors (OIDC, OAuth, SAML), the upstream identity provider may also have an active session. Future work should include:
- Implement
LogoutConnectorinterface (see above) - OIDC connectors use
end_session_endpointfrom discovery - SAML connectors use Single Logout (SLO)
- Redirect user to upstream after Dex logout
This is tracked as a future improvement.
Discovery Updates
func (s *Server) constructDiscovery(ctx context.Context) discovery {
d := discovery{
// ...existing fields...
}
if s.sessionsEnabled {
d.EndSessionEndpoint = s.absURL("/logout")
}
return d
}
Login Template Updates
When sessions are enabled, add "Remember Me" checkbox to authentication flow.
Template Data
The server passes these values to templates:
type templateData struct {
// ...existing fields...
// SessionsEnabled indicates if sessions feature is active
SessionsEnabled bool
// RememberMeChecked is the default checkbox state
// Set from config: sessions.rememberMeCheckedByDefault
RememberMeChecked bool
}
For PasswordConnector (login form exists in Dex):
<!-- templates/password.html -->
<form method="post">
<!-- existing fields -->
{{ if .SessionsEnabled }}
<div class="remember-me">
<input type="checkbox" id="remember_me" name="remember_me" value="true"
{{ if .RememberMeChecked }}checked{{ end }}>
<label for="remember_me">Remember me</label>
</div>
{{ end }}
<button type="submit">Login</button>
</form>
For CallbackConnector (no login form in Dex):
For OAuth/OIDC/SAML connectors, the user is redirected to upstream IDP and there's no Dex login form.
Show on Approval Page (recommended): Add "Remember Me" checkbox to the approval/consent page. User sees it after returning from upstream IDP, before granting consent.
<!-- templates/approval.html -->
<form method="post">
<!-- existing scope approval fields -->
{{ if .SessionsEnabled }}
<div class="remember-me">
<input type="checkbox" id="remember_me" name="remember_me" value="true"
{{ if .RememberMeChecked }}checked{{ end }}>
<label for="remember_me">Remember me on this device</label>
</div>
{{ end }}
<button type="submit" name="approval" value="approve">Grant Access</button>
</form>
When skipApprovalScreen is true: If approval screen is skipped, the rememberMeCheckedByDefault config determines cookie persistence:
false(default): Session cookie (deleted on browser close)true: Persistent cookie (survives browser restart)
Remember Me Behavior (Keycloak-like):
- AuthSession is ALWAYS created on successful authentication regardless of checkbox
- Checkbox controls cookie persistence only:
- Unchecked: Session cookie - expires when browser closes. SSO works within browser session.
- Checked: Persistent cookie - survives browser restart until
absoluteLifetimeexpires.
Connector Type Considerations
CallbackConnector (OIDC, OAuth, SAML, GitHub, etc.):
- Session created after successful callback
- Upstream tokens stored in refresh token's ConnectorData (not in session)
- Identity refresh via RefreshConnector when refresh token is used
PasswordConnector (LDAP, local passwords):
- Session created after successful password verification
- No upstream tokens
- Identity refresh re-validates against password backend when refresh token is used
Both types work the same way with sessions - the connector type only affects:
- Initial authentication flow (redirect vs password form)
- How identity refresh works (via refresh tokens, not sessions)
Connector Configuration Changes
Sessions reference a ConnectorID, but connector configuration may change after session creation (e.g., OIDC issuer URL changes, LDAP server replaced, connector removed entirely).
Behavior: Dex does NOT automatically invalidate sessions when connector configuration changes. This is by design - Dex has no mechanism to detect configuration changes at runtime, and connectors are typically reconfigured during planned maintenance.
Administrator responsibility: When connector configuration changes in a way that invalidates existing user identities (e.g., connector removed, upstream IdP replaced), administrators should:
- Terminate affected sessions via gRPC admin API (future:
DexSessions.TerminateByConnector(connectorID)) - Or wait for sessions to expire naturally
- Or restart Dex with
DEX_SESSIONS_ENABLED=falsetemporarily to force re-authentication
If a session references a connector that no longer exists, the session will fail gracefully at the next use: GetConnector() will return an error, and the user will be redirected to authenticate again.
Risks and Mitigations
Security Risks
| Risk | Mitigation |
|---|---|
| Session hijacking | Secure cookie flags (HttpOnly, Secure, SameSite), short idle timeout |
| Session fixation | Generate new session ID after authentication (see below) |
| CSRF on logout | GET shows confirmation page, POST performs logout |
| Cookie theft | Bind session to fingerprint (IP range, partial user agent) - optional |
| Storage exposure | Session IDs are random 256-bit values, no sensitive data in cookie |
Session Fixation Protection
Session fixation attacks occur when an attacker sets a known session ID in a victim's browser before authentication, then hijacks the session after the victim logs in.
References:
Mitigations implemented:
- Regenerate session ID on authentication: When a user successfully authenticates, ALWAYS generate a new
AuthSession.IDeven if a session already exists. Never reuse a pre-authentication session ID.
// This is not the real method signature, but the implementation example of a specific behavior.
func (s *Server) onSuccessfulAuthentication(w http.ResponseWriter, userID, connectorID, clientID string, rememberMe bool) {
// ALWAYS generate new session ID - prevents session fixation
newSessionID := NewSessionID()
// Create or update AuthSession with NEW ID
authSession := &AuthSession{
ID: newSessionID, // Always new, never reuse
ClientStates: make(map[string]*ClientAuthState),
CreatedAt: time.Now(),
// ...
}
// Set cookie with new session ID
s.setSessionCookie(w, newSessionID, rememberMe)
}
-
Don't accept session IDs from URL parameters: Session IDs are ONLY accepted from cookies, never from query parameters or POST data.
-
Strict cookie settings:
HttpOnly,Secure,SameSite=Laxprevent common session theft vectors. -
Session binding (optional future enhancement): Bind session to client characteristics (IP range, user agent) to detect stolen cookies.
Handling existing sessions during authentication:
When a user authenticates and an existing AuthSession is found:
- Generate a completely new session ID
- Copy relevant state from old session to new session (if any)
- Delete the old
AuthSessionfrom storage - Set cookie with new session ID
This ensures that even if an attacker set a session cookie before authentication, they cannot use it after the victim logs in.
Operational Risks
| Risk | Mitigation |
|---|---|
| Storage growth | AuthSessions are GC'd on inactivity; UserIdentities are per-user like OfflineSessions; admin API allows cleanup |
| Storage performance | Additional read per request to resolve session cookie. Impact depends on backend — see note below |
| Migration complexity | Feature flag allows gradual rollout, no breaking changes |
Storage Performance Note
Enabling sessions introduces an additional storage read on each authorization request (to resolve the session cookie to an AuthSession). The actual performance impact depends on the storage backend:
- SQL (Postgres, MySQL, SQLite): Session lookup by primary key is a single indexed read — negligible overhead
- etcd: Single key-value lookup — negligible overhead
- Kubernetes CRDs: GET by resource name — slightly higher latency than SQL/etcd but still within acceptable bounds (may require priority&fairness tuning)
- Memory: In-process map lookup — no overhead
At this stage, we do not have production metrics to quantify the exact impact. The storage access pattern is identical to existing OfflineSessions lookups (single record by key), which are already proven in production. It is recommended to monitor storage latency after enabling sessions and adjusting validIfNotUsedFor if the GC frequency needs tuning.
Breaking Changes
None - Sessions are opt-in via feature flag and configuration. Existing deployments continue to work without changes.
Rollback Plan
Sessions are fully controlled by the DEX_SESSIONS_ENABLED feature flag. Rollback is straightforward:
- Disable feature flag: Set
DEX_SESSIONS_ENABLED=false(or remove it) - Immediate effect: Dex stops creating, reading, and validating sessions. All authorization requests proceed as before sessions were introduced — connector authentication on every request, no SSO, no session cookies
- Cookie cleanup: Existing session cookies in browsers become inert — Dex ignores them when sessions are disabled. They expire naturally per their MaxAge or when the browser is closed
- Storage cleanup:
AuthSessionandUserIdentityrecords remain in storage but are unused. They can be cleaned up manually or left to accumulate no further growth - No downtime required: Feature flag can be toggled without restart if environment variable reload is supported; otherwise, a rolling restart is sufficient
Key guarantee: Disabling the feature flag returns Dex to its pre-sessions behavior with zero side effects. No existing functionality (refresh tokens, connector authentication, token issuance) depends on sessions. Additional tables in the database cost nothing when the feature flag is disabled: they remain unused schema objects and can be deleted later if desired.
Migration Path
- Deploy new Dex version - storage migrations create
AuthSessionandUserIdentitytables/resources automatically (no feature flag needed for schema) - Enable feature flag
DEX_SESSIONS_ENABLED=truewhen ready to use sessions - Add
sessions:configuration block - Sessions start being created for all new logins; "Remember Me" controls cookie persistence (session vs persistent cookie)
- Existing refresh tokens continue to work
Note: Storage schema changes (new tables/CRDs) are applied on startup regardless of feature flag. The feature flag only controls whether sessions are actually created and used. This simplifies deployment - you can deploy the new version, then enable sessions later without another deployment.
Alternatives
1. Stateless Sessions (JWT in Cookie)
Approach: Store session data directly in a signed/encrypted JWT cookie.
Pros:
- No server-side storage required
- Scales horizontally without shared state
Cons:
- Cannot revoke sessions without blocklist
- Cookie size limits (~4KB)
- Cannot store consent history or client tracking for logout
- No server-side session list for logout
Decision: Rejected. Server-side sessions are required for proper logout and SSO.
2. Extend OfflineSessions
Approach: Add session data to existing OfflineSessions entity.
Pros:
- Reuses existing storage
- Simpler migration
Cons:
- OfflineSessions are per-connector, not per-browser
- Different lifecycle (refresh token vs browser session)
- Would complicate existing OfflineSessions logic
Decision: Rejected. Clean separation is better for maintainability.
3. External Session Store (Redis)
Approach: Use Redis for session storage instead of existing backends.
Pros:
- Built-in TTL support
- Fast reads/writes
- Proven session store
Cons:
- Adds infrastructure dependency
- Against Dex's simplicity philosophy
- Doesn't work with Kubernetes CRD backend
Decision: Rejected. Must work with existing storage backends.
4. Do Nothing
Approach: Keep using refresh tokens as implicit sessions.
Cons:
- Cannot implement OIDC conformance features
- No proper SSO
- No proper logout
- Blocks future features (2FA, etc.)
Decision: Rejected. These features are essential for enterprise adoption.
Future Improvements
-
Identity Refresh for Long-Lived Sessions
- Periodic refresh of user identity from connector during active session
- Configurable refresh interval
- Refresh on token request option
- Handle connector revocation (terminate session)
-
Upstream Connector Logout
- Redirect to upstream IDP logout endpoint after Dex logout
- Support RP-Initiated Logout towards upstream OIDC providers
- SAML Single Logout (SLO) support
- Configurable per-connector logout URLs
-
Session Introspection Endpoint
- Implement session check endpoint similar to RFC 7662 Token Introspection
- Could enable replacing OAuth2 Proxy in some deployments
- Endpoint:
GET /session/introspector similar - Returns session validity and user claims
- Useful for reverse proxies to validate session cookies directly
-
Front-Channel Logout
- Implement OIDC Front-Channel Logout 1.0
- Notify client applications when user logs out via iframes
- Requires client
logoutURLconfiguration
-
2FA/MFA Support
- Store TOTP secrets in user profile
- Add MFA enrollment flow
- Step-up authentication for sensitive operations
- WebAuthn/Passkey support
-
Session Management API
- List active sessions via gRPC API
- Revoke sessions via gRPC API
- Session activity audit log
-
Back-Channel Logout
- Implement OIDC Back-Channel Logout
- Server-to-server logout notifications
-
Account Linking
- Link multiple connector identities to single user
- Switch between linked identities
-
Device/Session Fingerprinting
- Optional session binding to client characteristics
- Anomaly detection for session theft
-
Per-Connector Session Policies
- Different session lifetimes per connector
- Different SSO policies per connector
-
Session Impersonation for Admin
- Admin can impersonate user sessions for debugging
- Audit logging for impersonation
-
Consent Management UI
- User-facing page to view/revoke consents
- GDPR compliance features