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
- OpenID Connect Core 1.0 — §2 (
auth_time,acr,amr) - OpenID Connect RP-Initiated Logout 1.0
- OpenID Connect Back-Channel Logout 1.0
- OpenID Connect Front-Channel Logout 1.0 — referenced for the deliberate non-implementation
- RFC 6265bis —
__Host-cookies,SameSite
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:
| Field | Meaning |
|---|---|
ID | Opaque 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. |
Subject | The OP-internal stable user id. Becomes sub in ID Tokens (projected per client when pairwise subjects are enabled). |
AuthTime | When the user authenticated. Copied into the auth_time of tokens issued from this session. |
ACR | Authentication Context Class Reference — the assurance level the session satisfies. |
AMR | Authentication Methods References (RFC 8176) — pwd, otp, mfa, hwk, … |
ChooserGroupID | Multi-account chooser group. Multiple sessions in one browser share this. |
ExpiresAt, CreatedAt, UpdatedAt | Lifecycle 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:
| Cookie | Purpose | Scheme |
|---|---|---|
__Host-oidc_session | Persistent session pointer. | AES-256-GCM AEAD over an opaque payload. __Host- prefix forces same-origin only. SameSite=Lax; 14-day max age. |
__Host-oidc_interaction | In-flight interaction (login form, MFA challenge) state. | Same AEAD; one-hour TTL. |
__Host-oidc_csrf | Double-submit CSRF token for the interaction form. | HMAC-only (no AEAD). SameSite=Strict; browser-session cookie. |
__Host-oidc_logout_csrf | Double-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_locale | Remembers 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:
| Request | What happens |
|---|---|
prompt=login | The session is not reused. The user authenticates again in full. |
max_age shorter than the time since the session's auth_time | Same: full re-authentication. |
acr_values or an essential acr the session's acr does not meet | A 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 step | The chain runs from the session's subject, so Primary is not repeated. |
| None of the above | The 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:
- Verifies the
id_token_hint: its signature, thataudnames a registered client, and thattypis absent orJWT(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. - Shows a confirmation page unless the hint's
submatches 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. - Resolves the logout scope and deletes the selected session rows.
- If the
post_logout_redirect_uriis registered for the client, redirects the browser back withstateechoed.
logout_scope | Sessions destroyed |
|---|---|
| absent (default) | Every session in the browser's chooser group. |
current | Only the active session. If a sibling survives, the OP rebinds the cookie to it. |
| empty, unknown, or repeated | None: 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=Laxdefault 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:
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) andFields. The signed-out template getsinteraction.LogoutSignedOutTemplateData:Locale,TitleandBody.Title,BodyandButtonare already resolved in the request's locale. Fieldsmust 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 norangeover.Fieldswould fail every confirmation with a400, soop.Newexecutes 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
ContentSecurityPolicykeeps 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,sandboxincluded, so keepingsandbox allow-forms allow-same-originis your decision: withoutallow-formsthe confirmation form cannot submit. The value passes throughinteraction.NormalizeCSPatop.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
500and no partial output. - Unchanged.
Referrer-Policystayssame-originbecause the confirmation POST depends on a same-originOrigin. The error page is not templated. - Applies in every
op.WithUImode. Logout is rendered by the server in SPA mode too, andop.SPAUIhas 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:
| Approach | How it works | Constraint |
|---|---|---|
| Refresh token | The 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 iframe | The 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.
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 stores | Cascade 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 nil | The cascade is skipped. ATs expire naturally at their exp. |
JWT revocation strategy None | The 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:
| Posture | Backend for SessionStore | Trade-off |
|---|---|---|
| Hot/cold split (recommended for high-traffic) | Redis (volatile) | Low session-mutation latency. BCL becomes best-effort if eviction races logout. |
| All-durable | Same SQL cluster as the transactional store | BCL 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:
srv.Shutdown(ctx) // stop accepting
provider.Shutdown(ctx) // then drain detached fan-outAudit events on the session lifecycle
| Event | Fires on |
|---|---|
audit.SessionCreated | New session minted. |
audit.SessionDestroyed | Session row deleted (logout, eviction, GC). |
audit.SessionAlreadyAbsent | A logout targeted a session that was already absent or expired. |
audit.SessionDestroyFailed | Session deletion hit a store failure; the request is a static 503 and state is preserved. |
audit.LogoutClientLookupFailed | Client registry lookup failed while resolving a logout client; the wire response remains a uniform refusal. A clean missing client emits no event. |
audit.LogoutRPInitiated | Reserved — the OP does not emit a separate event for the RP's logout request. |
audit.LogoutTokenRevokeFailed | Logout-token or session cascade revocation hit a store failure. |
audit.LogoutBackChannelDelivered | RP returned 2xx for a logout_token POST. |
audit.LogoutBackChannelFailed | RP returned non-2xx, the network errored, or the deny-list blocked the URL. |
audit.LogoutBackChannelResolveFailed | Grant-based target resolution failed. |
audit.LogoutBackChannelOverflow | The bounded target list overflowed. |
audit.BCLNoSessionsForSubject | A 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.
Read next
- Use case: Back-Channel Logout — wiring an RP's
backchannel_logout_uri, signing-key choices, and thelogout_tokenpayload. - Design judgments — judgments #5, #10, and #17 cover the explicit reads behind the logout posture.
- Operations: multi-instance — running the OP behind a load balancer when sessions are shared via a volatile store.