Use case — Back-Channel Logout
What is "back-channel logout"?
A user typically signs into multiple apps (RPs) through the same OP — "sign in with Acme" buttons all share one identity session. When the user clicks log out at one RP, the other RPs still hold their own local cookies; without coordination, the user looks signed in at app B even though they signed out at app A.
Back-channel logout is the OP-driven fan-out that closes that gap. Each RP registers a server-side callback URL with the OP. When the session ends, the OP POSTs a signed logout_token directly to every eligible RP target in the subject's grant-derived audience (server to server, behind the user's back — hence "back-channel"). Each RP verifies the token and drops its local cookie.
The alternative — front-channel logout — embeds an <iframe> per RP and depends on third-party cookies, which modern browsers progressively break. Back-channel is the deployable choice.
Specs referenced on this page
- OpenID Connect Back-Channel Logout 1.0
- RFC 7519 — JWT (the logout token shape)
- RFC 8417 — Security Event Token (SET) — the
eventsclaim shape - RFC 1918 — Private IPv4 ranges (used by the SSRF defence below)
Quick refresher
logout_token— a short-lived JWT the OP signs and POSTs to each RP, naming the subject (sub) whose session ended. It is not an access token; the RP only verifies it and drops local state.- SET (Security Event Token, RFC 8417) — a JWT shape designed for security event delivery. The
eventsclaim slots an event-type key (herehttp://schemas.openid.net/event/backchannel-logout) so a generic SET receiver can dispatch to the right handler.
Source:
examples/42-back-channel-logout
Architecture
The OP signs a logout_token per RP and POSTs it to that RP's backchannel_logout_uri. The token contains:
| Claim | Meaning |
|---|---|
iss | OP issuer |
aud | The RP's client_id |
iat, jti | Issuance time + replay nonce |
sub | Whose session ended. sid is never emitted — see below |
events | {"http://schemas.openid.net/event/backchannel-logout": {}} |
The RP verifies the signature and aud, drops the local session, and returns 200.
Wiring
Per-client BackchannelLogoutURI opts the RP in:
op.WithStaticClients(op.PublicClient{
ID: "rp-a",
RedirectURIs: []string{"https://rp-a.example.com/callback"},
Scopes: []string{"openid", "profile"},
BackchannelLogoutURI: "https://rp-a.example.com/oidc/backchannel-logout",
BackchannelLogoutSessionRequired: false, // sid is not supported; sub identifies the user
})The BackchannelLogoutURI field also exists on op.ConfidentialClient and op.PrivateKeyJWTClient — every typed seed accepts it.
Library-wide knobs:
op.New(
/* ... */
op.WithBackchannelLogoutHTTPClient(myHTTPClient), // mTLS / custom timeouts
op.WithBackchannelLogoutTimeout(5 * time.Second),
op.WithBackchannelFanOutBudget(30 * time.Second), // whole detached fan-out; separate from per-RP timeout
)Local demos and CI fixtures that bind a stub RP on loopback can opt into plain HTTP only for loopback backchannel_logout_uri values:
op.WithAllowInsecureBackchannelLogoutForDev()That option widens both the registration-time URL validator and the runtime SSRF gate for 127.0.0.1, [::1], and localhost only. It is not a production shortcut; public hosts and non-loopback private networks still require the explicit production posture below.
SSRF defense
Private-network destinations are refused by default
The deliverer refuses to POST to a backchannel_logout_uri whose host resolves to a loopback / link-local / RFC 1918 / IPv6 ULA address. Without this, an RP that can register an arbitrary URL becomes an SSRF oracle into the OP's internal network.
The dial-time deny-list is layered on a URL-shape gate at registration time: backchannel_logout_uri MUST be https, carry no fragment, no userinfo, and a non-empty host — https://attacker:[email protected]/... and https://rp.example.com/cb#anchor both fail with invalid_client_metadata. backchannel_logout_session_required=true is unsupported and rejected; setting it does not enable sid delivery.
Embedders fronting their RPs with private DNS opt in:
op.WithBackchannelAllowPrivateNetwork(true)This must be a deliberate choice — the option is the visible site for the security trade-off.
How the audience is resolved (and why it is bounded)
Fan-out resolves its audience from grants, not from session rows. The coordinator takes the ending session's subject and asks the grant store for the distinct clients that subject has consented to, through store.GrantClientLister.ListClientIDsBySubject — a keyset-paginated view separate from ListBySubject, because one subject can hold many historical grant rows per client. Every stage of the fan-out is bounded on purpose:
| Bound | Default | What it caps |
|---|---|---|
| Deduplicated audience | DefaultMaxTargets (256) | clients notified for one logout; the grant query itself is capped, not filtered afterwards |
| Concurrent deliveries | DefaultMaxConcurrentDeliveries (8) | simultaneous outbound POSTs |
| Whole detached fan-out | 30 seconds (DefaultFanOutBudget) | total wall-clock budget for one logout event |
When the audience page comes back with a NextCursor, more clients matched than the cap allows and the coordinator emits an overflow audit event carrying that cursor rather than silently truncating. A single unreachable RP surfaces as a per-target audit event instead of failing the whole fan-out.
Before detached work starts, /end_session groups the snapshot by subject, so multiple browser sessions for one subject create one fan-out. The grant query is distinct by client ID, so each eligible client receives at most one Logout Token for that subject; the target cap counts this deduplicated set.
During detached target resolution, a client-registry transport fault is recorded as op.AuditLogoutBackChannelResolveFailed, while a clean missing client is skipped without an event. The /end_session request's own id_token_hint / client_id lookup has separate op.AuditLogoutClientLookupFailed semantics: the wire refusal is uniform and a clean missing client is silent. See the audit event catalog.
op.WithBackchannelFanOutBudget changes the whole-event budget. It is independent of op.WithBackchannelLogoutTimeout, which limits each RP request. When the budget expires, remaining targets are recorded as failed and the detached work converges; Provider.Shutdown(ctx) waits for outstanding fan-outs when the process is draining.
backchannel_logout_session_supported is false
Discovery advertises it as false, and that follows directly from grant-based resolution: the OP cannot prove that an OP-side session identifier belongs to a particular RP, so a sid is never copied into a Logout Token. RPs must key their local session teardown on sub. Keep BackchannelLogoutSessionRequired false or omit it; true is unsupported and rejected.
When the fan-out resolves nothing
If the subject has no eligible grant-derived RP targets — every grant is revoked, or a stale record points to a client that no longer exists — the fan-out has nothing to notify. The library surfaces that as an audit event:
| Event | Meaning |
|---|---|
op.AuditBCLNoSessionsForSubject | /end_session named a session, but grant-based audience resolution produced zero eligible RP targets. |
The event fires only when /end_session has named a session and grant-based audience resolution produces zero eligible RP targets. If a volatile SessionStore evicts the row before /end_session can snapshot it, the handler does not create a detached notice, so no fan-out or AuditBCLNoSessionsForSubject event starts. The extras carry the configured op.SessionDurabilityPosture (SessionDurabilityVolatile or SessionDurabilityDurable) as context for trigger/snapshot loss versus a no-target outcome; the posture does not claim that the no-target event itself proves eviction.
Front-channel logout (a different mechanism)
OIDC Front-Channel Logout 1.0 (browser-side iframe fan-out) is a separate spec the library intentionally does not implement. Back-channel is the deployable choice: no third-party cookie dependency, works across origins, doesn't require the user's browser to be open at the moment fan-out happens.