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.
  • Deleting only the cookie in the browser leaves the row on the OP. Deleting only the row leaves a cookie that points at nothing, so the user's next request finds no session and signs in again.

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. It is not emitted as a sid claim: the Back-Channel Logout Tokens this OP sends carry sub only.
SubjectThe OP-internal stable user id. Becomes sub in ID Tokens (projected per client when pairwise subjects are enabled).
AuthTimeWhen the user authenticated. Copied into the auth_time of tokens issued from this session.
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). Its encrypted payload holds the chooser-group ID, the current session ID, and the time the cookie was written; the session's lifetime is enforced against the stored row, so a replayed cookie cannot extend it. The library uses five 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; 14-day max age.
__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; browser-session cookie.
__Host-oidc_logout_csrfDouble-submit CSRF token that carries the /end_session confirmation from its GET page to its POST.HMAC-only (no AEAD). SameSite=Strict; five-minute TTL.
__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 など) without violating any library invariant. See design judgment #10 for the trade-off.

Reusing a session at /auth ​

When a browser arrives at /auth with a live session, the OP decides per request whether that session may serve it:

RequestWhat happens
prompt=loginThe session is not reused. The user authenticates again in full.
max_age shorter than the time since the session's auth_timeSame: full re-authentication.
acr_values or an essential acr the session's acr does not meetA step-up runs. The issued tokens keep the session's acr, amr, and auth_time, and the step-up factors are audited as mfa.* events.
A LoginFlow rule or the Decider demands another stepThe chain runs from the session's subject, so Primary is not repeated.
None of the aboveThe session serves the request, and tokens carry its auth_time, acr, and amr.

With prompt=none, any row that would need a user interface returns an error instead. Picking a stale or weaker session in the account chooser prompts for re-authentication rather than failing the request.

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: its signature, that aud names a registered client, and that typ is absent or JWT (a logout token or access token is refused). An expired ID Token is accepted, because a user logging out from a stale tab is the common case.
  2. Shows a confirmation page unless the hint's sub matches the subject of the session cookie and the logout ends no other account. A hint for a different subject, or no hint, always gets the page, and so does a matching hint when the logout would also end other accounts signed in on the same browser.
  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.
logout_scopeSessions destroyed
absent (default)Every session in the browser's chooser group.
currentOnly the active session. If a sibling survives, the OP rebinds the cookie to it.
empty, unknown, or repeatedNone: the request is rejected with HTTP 400 before any state changes.

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 to end the OP's session for this browser. The RP that initiated the logout already knows about it; 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 opts in with op.WithAllowPrivateNetwork(op.OutboundBackchannel). Redirects are never followed. BackchannelLogout.Transport swaps in a custom http.RoundTripper (an internal CA, shared instrumentation) without lifting the deny-list. Failures are logged via audit.LogoutBackChannelFailed. Successes via audit.LogoutBackChannelDelivered. A session-bearing logout notice whose grant-derived RP audience is empty emits audit.BCLNoSessionsForSubject — useful for distinguishing "no RP targets resolved" from "delivery failed".

Detached fan-out has two independent time limits: BackchannelLogout.Timeout (passed through op.WithBackchannelLogout) bounds one RP's HTTP delivery (5 seconds by default), while BackchannelLogout.FanOutBudget 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.

Brand the logout pages ​

/end_session renders two pages itself: the confirmation page shown when the request arrives without proof of the user's intent, and the signed-out page shown when the request carried no post_logout_redirect_uri. By default both are fixed templates in which only the wording changes, through the logout.* keys (i18n). op.WithLogoutUI replaces both with your own html/template templates:

go
confirm := template.Must(template.New("confirm").Parse(`<!doctype html>
<html lang="{{.Locale}}"><head><meta charset="utf-8"><title>{{.Title}}</title>
<link rel="stylesheet" href="/static/logout.css"></head>
<body><main>
  <h1>{{.Title}}</h1>
  <p>{{.Body}}</p>
  <form method="{{.SubmitMethod}}" action="{{.SubmitAction}}">
    {{range .Fields}}<input type="hidden" name="{{.Name}}" value="{{.Value}}">
    {{end}}<button type="submit">{{.Button}}</button>
  </form>
</main></body></html>`))

signedOut := template.Must(template.New("signed-out").Parse(`<!doctype html>
<html lang="{{.Locale}}"><head><meta charset="utf-8"><title>{{.Title}}</title></head>
<body><main><h1>{{.Title}}</h1><p>{{.Body}}</p></main></body></html>`))

op.New(
  /* required options */
  op.WithLogoutUI(op.LogoutUI{
    ConfirmTemplate:       confirm,
    SignedOutTemplate:     signedOut,
    ContentSecurityPolicy: "default-src 'none'; style-src 'self'; img-src 'self' data:; sandbox allow-forms allow-same-origin",
  }),
)
  • Both templates are required, and the option may be called once. A sign-out never changes appearance halfway through.
  • Template data. The confirmation template is executed with interaction.LogoutConfirmTemplateData: Locale, Title, Body, Button, SubmitMethod (always "POST"), SubmitAction, ClientName (the requesting client's display name, empty when none is identified) and Fields. The signed-out template gets interaction.LogoutSignedOutTemplateData: Locale, Title and Body. Title, Body and Button are already resolved in the request's locale.
  • Fields must be ranged. Each entry is a hidden input (Name, Value) that the form has to submit back unchanged, and the double-submit CSRF token is one of them. A template that has no range over .Fields would fail every confirmation with a 400, so op.New executes the template once against sample fields and rejects it when any value is missing from the output.
  • The CSP replaces the whole header. An empty ContentSecurityPolicy keeps the default, default-src 'none'; frame-ancestors 'none'; base-uri 'none'; sandbox allow-forms allow-same-origin. A value you declare replaces all of it, sandbox included, so keeping sandbox allow-forms allow-same-origin is your decision: without allow-forms the confirmation form cannot submit. The value passes through interaction.NormalizeCSP at op.New, which refuses directives that are not yours to relax, and again at render time; if it fails then, the strict default stays.
  • Failure is closed. The template is rendered into a buffer first. If execution fails, the OP sends its fixed error page with 500 and no partial output.
  • Unchanged. Referrer-Policy stays same-origin because the confirmation POST depends on a same-origin Origin. The error page is not templated.
  • Applies in every op.WithUI mode. Logout is rendered by the server in SPA mode too, and op.SPAUI has no logout route.

Sessions in a SPA ​

A SPA is a relying party that holds its own tokens. The OP session cookie, __Host-oidc_session, belongs to the OP origin, and the SPA never reads it. Two things follow: how a SPA stays signed in, and how far a logout reaches.

Staying signed in. There are two ways to renew, and they behave differently:

ApproachHow it worksConstraint
Refresh tokenThe SPA calls /token with grant_type=refresh_token. No cookie and no OP page are involved.The chain's lifetime and rotation rules apply; see Refresh tokens. When the refresh fails, treat the user as signed out.
prompt=none in a hidden iframeThe SPA sends an authorization request that must not show UI; a live OP session answers with a code, otherwise the OP answers login_required.The browser has to send the OP's session cookie from inside the frame. The cookie is SameSite=Lax, so this works only when the SPA and the OP are same-site; in a cross-site frame the cookie is withheld and the answer is always login_required.

Prefer the refresh token. It does not depend on third-party cookie policy, and it does not consult the OP session at all.

What logout reaches. /end_session ends the OP session or sessions of that browser, retires the access and refresh tokens issued under the subject's grants (see below), and sends a Back-Channel Logout token to each relying party that registered a backchannel_logout_uri. The token carries sub only, with no sid, so a receiving server can end every session it holds for that subject but cannot single one out. There is no front-channel logout, so a SPA open in another tab hears nothing. It finds out when its next refresh fails, or when its short-lived access token is refused by the API. A SPA with no server component also has nowhere to receive the back-channel POST; put the receiver in the backend that serves it. To sign out from the SPA, discard its own tokens, then send the browser to /end_session with id_token_hint and a registered post_logout_redirect_uri.

End-session cascade ​

/end_session does more than delete the cookie. For each distinct subject in the sessions being destroyed, the library walks every grant the subject holds and retires the credentials issued under it: JWT and opaque access tokens, and refresh tokens. The grant records themselves stay, so the user's consent is remembered. JWT access tokens become inactive at OP-served boundaries (/userinfo, /introspect); opaque access tokens become inactive at every RS that introspects.

Ending a session cascades, but not to every token
Above, the session model: five 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, each grant is walked and the access and refresh tokens under it are revoked, and a logout token is posted to every registered back-channel logout URI. A note marks that a JWT access token is refused only by the OP and an opaque one only once each resource server introspects it.session modelBrowser cookiesfive __Host-oidc_* cookiesscoped to the OP origin, never the RProw idSessionStoreone row per session, all OP-side stateID · Subject · AuthTime · ACR · AMRend-session cascade/end_sessionRP-initiated logoutSessionStorethe row is deletedGrantsone teardown per grantTokens under each grantaccess and refresh revokedBack-channel logoutlogout_token → each RPHow far it reachesJWT: refused by the OPopaque: needs RS introspectiondepends on every RS
The first two steps are synchronous and under the OP's control. Token revocation and back-channel logout 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 wired (default with the bundled adapters)Logout cascades. JWT ATs are rejected at /userinfo and /introspect; opaque ATs are rejected at every RS; refresh tokens stop refreshing.
Grants left nilThe cascade is skipped. ATs expire naturally at their exp.
JWT revocation strategy NoneThe JWT half is skipped; opaque ATs and refresh tokens are still revoked.

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 (audit.BCLNoSessionsForSubject) 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
audit.SessionCreatedNew session minted.
audit.SessionDestroyedSession row deleted (logout, eviction, GC).
audit.SessionAlreadyAbsentA logout targeted a session that was already absent or expired.
audit.SessionDestroyFailedSession deletion hit a store failure; the request is a static 503 and state is preserved.
audit.LogoutClientLookupFailedClient registry lookup failed while resolving a logout client; the wire response remains a uniform refusal. A clean missing client emits no event.
audit.LogoutRPInitiatedReserved — the OP does not emit a separate event for the RP's logout request.
audit.LogoutTokenRevokeFailedLogout-token or session cascade revocation hit a store failure.
audit.LogoutBackChannelDeliveredRP returned 2xx for a logout_token POST.
audit.LogoutBackChannelFailedRP returned non-2xx, the network errored, or the deny-list blocked the URL.
audit.LogoutBackChannelResolveFailedGrant-based target resolution failed.
audit.LogoutBackChannelOverflowThe bounded target list overflowed.
audit.BCLNoSessionsForSubjectA session-bearing /end_session notice resolved zero grant-derived RP targets.

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