Skip to content

Sessions and logout

A session is the OP-side state that says "this browser was authenticated by user X at time T with assurance level A." A small encrypted cookie binds the browser back to that state. Logout means killing the state — and, optionally, telling the RPs that depended on it that the user is gone.

Specs referenced on this page

Mental model in 30 seconds

  • The session lives on the OP, not in the cookie.
  • The cookie is just an encrypted pointer to the session row.
  • "Logout" = delete the row. Optional fan-out tells RPs about it.
  • If you only delete the cookie, the OP forgets the user. If you only kill the row, the cookie next request reads no session and the user is forced to re-authenticate.

How this library encodes the session

The OP keeps a row in store.SessionStore with:

FieldMeaning
IDOpaque OP browser-session identifier; used for cookie/session lookup and audit correlation, but not emitted as the RP-specific sid claim in current sub-only Back-Channel Logout Tokens.
SubjectThe OP-internal stable user id. Becomes sub in ID Tokens.
AuthTimeWhen the user authenticated. Becomes auth_time in ID Tokens.
ACRAuthentication Context Class Reference — the assurance level the session satisfies.
AMRAuthentication Methods References (RFC 8176) — pwd, otp, mfa, hwk, …
ChooserGroupIDMulti-account chooser group. Multiple sessions in one browser share this.
ExpiresAt, CreatedAt, UpdatedAtLifecycle timestamps.

The cookie that points at this row is __Host-oidc_session (defined in internal/cookie/profile.go). The library uses four cookies in total:

CookiePurposeScheme
__Host-oidc_sessionPersistent session pointer.AES-256-GCM AEAD over an opaque payload. __Host- prefix forces same-origin only. SameSite=Lax.
__Host-oidc_interactionIn-flight interaction (login form, MFA challenge) state.Same AEAD; one-hour TTL.
__Host-oidc_csrfDouble-submit CSRF token for the interaction form.HMAC-only (no AEAD). SameSite=Strict.
__Host-oidc_localeRemembers the user's chosen UI locale across interaction pages.Plain text (not encrypted); one-year TTL. SameSite=Lax.
__Host- prefix — what's that?

A cookie name that starts with __Host- is, per RFC 6265bis, accepted by browsers only when the cookie also has Secure, Path=/, and no Domain attribute — meaning it is bound to exactly the OP's origin. Subdomain compromise can't forge it; a sibling domain can't read it. This library refuses to boot on plain HTTP precisely because the __Host- prefix would not survive.

The session row goes through store.SessionStore — a substore that the embedder may serve from a volatile backend (Redis, Memcached) without violating any library invariant. See design judgment #10 for the trade-off.

Logout taxonomy

Three OIDC specs deal with logout. The library implements two of them.

RP-Initiated Logout 1.0

The RP redirects the browser to:

GET /end_session?id_token_hint=<id_token>&post_logout_redirect_uri=<uri>&state=<opaque>

The OP does the following:

  1. Verifies the id_token_hint (matches a session it issued).
  2. Optionally renders an interstitial confirmation page (recommended for "are you sure?" UX).
  3. Resolves the logout scope and deletes the selected session rows.
  4. If the post_logout_redirect_uri is registered for the client, redirects the browser back with state echoed.

The default scope is the active chooser group: every session in that browser group is destroyed. Add the single-valued query parameter logout_scope=current to destroy only the active session; if a sibling survives, the OP rebinds the browser cookie to that sibling. An empty value, an unknown value, or a repeated logout_scope is rejected with HTTP 400 before session state is changed.

An id_token_hint whose subject does not match the session cookie cannot bypass confirmation. If the session store has a transport failure while the OP is resolving or destroying the session, /end_session returns a static HTTP 503 page with no post-logout redirect and leaves both the cookie and the session row intact. An absent or already-expired session is a different, safe-to-continue case.

The confirmation and browser-facing error HTML from /end_session carry both X-Frame-Options: DENY and a Content-Security-Policy with frame-ancestors 'none', so neither page can be embedded.

For native and public clients, the same RFC 8252 loopback any-port rule used for redirect_uri also applies to post_logout_redirect_uri: a client may register a fixed loopback URI and return with a different ephemeral port at logout. Host, scheme, and path still have to match the registered shape; only the port varies.

The point is end the OP's session for this browser. The RP that initiated the logout already knows about it (it's the one that redirected); other RPs don't, unless the OP also runs Back-Channel Logout.

Back-Channel Logout 1.0

When /end_session terminates a session, the OP POSTs a signed logout_token JWT to each eligible RP target derived from that subject's grants. The RP validates the JWT and invalidates its own local session.

This is server-to-server. The browser is not involved, so it works whether the user closed the tab or switched browsers; the public session logout entry point is /end_session.

The library guards the outbound HTTP request with the same SSRF deny-list as JWKS / sector_identifier_uri: no private networks unless the embedder explicitly opts in. Failures are logged via op.AuditLogoutBackChannelFailed. Successes via op.AuditLogoutBackChannelDelivered. A session-bearing logout notice whose grant-derived RP audience is empty emits op.AuditBCLNoSessionsForSubject — useful for distinguishing "no RP targets resolved" from "delivery failed".

Detached fan-out has two independent time limits: op.WithBackchannelLogoutTimeout bounds one RP's HTTP delivery (5 seconds by default), while op.WithBackchannelFanOutBudget bounds the whole detached fan-out (30 seconds by default). The latter is a global event budget, not the per-RP timeout; Provider.Shutdown(ctx) also drains fan-outs that are still running.

Back-Channel Logout is best-effort under volatile sessions

If a volatile store.SessionStore evicts the row before /end_session can resolve and snapshot it, the browser logout trigger is lost and no detached fan-out starts. When the snapshot is captured, the RP audience is resolved from the grant store rather than by walking SessionStore rows; zero grant-derived RP targets are reported only for a session-bearing notice. The op.SessionDurabilityPosture knob provides context for trigger/snapshot loss versus a no-target outcome. See design judgment #10.

Front-Channel Logout 1.0 — not implemented

Front-Channel Logout works by the OP serving an HTML page with one <iframe> per RP's frontchannel_logout_uri; each iframe loads in third-party context and reads its own cookie to clear it. The mechanism depends on a third-party iframe being able to read its own cookie from an embedded context — a capability that mainstream browsers have removed:

  • Safari ITP since 2017
  • Firefox ETP since 2019
  • Chrome SameSite=Lax default since 2020
  • Third-party-cookie phase-out across 2024–2025

The library does not ship Front-Channel Logout, and the discovery document does not advertise frontchannel_logout_supported. Embedders that need fan-out logout use Back-Channel Logout 1.0, which is server-to-server and unaffected by browser cookie policies. See design judgment #5 for the full reasoning.

End-session cascade

/end_session is not just "delete the cookie." When the embedder has wired Grants and AccessTokens substores, the library walks every grant the subject holds and revokes the per-grant access-token shadow rows. JWT access tokens become inactive at OP-served boundaries (/userinfo, /introspect); opaque access tokens become inactive at every RS that introspects.

One session row, and how far ending it reaches
Above, the session model: four browser cookies scoped to the OP origin, of which one carries the id of a single SessionStore row that holds the OP-side state. Below, the end-session cascade: the row is deleted, grants are walked and revoked, access tokens are revoked, and a logout token is posted to every registered back-channel logout URI.session modelBrowser cookies__Host-oidc_session__Host-oidc_interaction__Host-oidc_csrf__Host-oidc_localescoped to the OP origin, never to the RPcarries the row idSessionStoreone row per session — all OP-side stateID · Subject · AuthTime · ACRAMR · ChooserGroupID · timestampsthe cookie holds the id and nothing elseend-session cascade/end_sessionRP-initiated logoutSessionStorethe row is deletedGrantswalked and marked revokedAccessTokensrevoked at the OP, directlyBack-Channel LogoutPOST logout_token → each RPhow far it actually reachesa JWT access token is refused by the OPat /userinfo and /introspectan opaque one goes inactive only onceeach resource server introspects itreach depends on every RS, not on the OP
Everything in the left column is synchronous and under the OP's control. The last two rows are not: a JWT already in flight stays syntactically valid until it expires, and a back-channel notification only helps for RPs that registered a URI and answer it.

The cookie is only a pointer — the session is the row in SessionStore. Clearing the cookie in the browser leaves that row intact, so the OP has to delete the row itself.

Wired storesCascade behaviour
Grants + AccessTokens (default with the bundled adapters)Logout cascades. ATs flip to revoked. JWT ATs are rejected at /userinfo; opaque ATs are rejected at every RS.
Either left nilCascade short-circuits silently. ATs expire naturally at their exp.

See design judgment #17 for the rationale and the asymmetry between JWT and opaque cascade reach.

Sessions in volatile vs durable storage

The session substore is intentionally separate from the transactional store (auth codes, refresh tokens, clients). Embedders typically pick:

PostureBackend for SessionStoreTrade-off
Hot/cold split (recommended for high-traffic)Redis (volatile)Low session-mutation latency. BCL becomes best-effort if eviction races logout.
All-durableSame SQL cluster as the transactional storeBCL delivery is integrity-bounded. Session writes share latency with token writes.

The op.WithSessionDurabilityPosture(...) option declares the embedder's choice so that the audit trail (op.AuditBCLNoSessionsForSubject) can be interpreted correctly. See the hot/cold split use case for a complete wiring.

For graceful shutdown, stop accepting HTTP traffic first and then drain the provider's detached work:

go
srv.Shutdown(ctx)      // stop accepting
provider.Shutdown(ctx) // then drain detached fan-out

Audit events on the session lifecycle

EventFires on
op.AuditSessionCreatedNew session minted.
op.AuditSessionDestroyedSession row deleted (logout, eviction, GC).
op.AuditSessionAlreadyAbsentA logout targeted a session that was already absent or expired.
op.AuditSessionDestroyFailedSession deletion hit a store failure; the request is a static 503 and state is preserved.
op.AuditLogoutClientLookupFailedClient registry lookup failed while resolving a logout client; the wire response remains a uniform refusal. A clean missing client emits no event.
op.AuditLogoutRPInitiatedReserved — the OP does not emit a separate event for the RP's logout request.
op.AuditLogoutTokenRevokeFailedLogout-token or session cascade revocation hit a store failure.
op.AuditLogoutBackChannelDeliveredRP returned 2xx for a logout_token POST.
op.AuditLogoutBackChannelFailedRP returned non-2xx, the network errored, or the deny-list blocked the URL.
op.AuditLogoutBackChannelResolveFailedGrant-based target resolution failed.
op.AuditLogoutBackChannelOverflowThe bounded target list overflowed.
op.AuditBCLNoSessionsForSubjectA session-bearing /end_session notice resolved zero grant-derived RP targets.

op.AuditLogoutRPInitiated is reserved catalog vocabulary; the OP does not emit a separate "RP asked" event. Correlate the resulting session and back-channel events instead.