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 RP (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: true, // request the "sid" claim on the logout token
})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),
)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:internal@rp.example.com/... and https://rp.example.com/cb#anchor both fail with invalid_client_metadata. backchannel_logout_session_required=true paired with an empty URI is also rejected, so a client cannot opt into sid delivery without a destination.
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 |
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.
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. A client registering backchannel_logout_session_required=true is asking for something the OP does not emit.
When the fan-out resolves nothing
If the subject holds no live grants — every one revoked, or a stale record whose client no longer exists — the fan-out has nothing to notify. The library surfaces that as an audit event:
| Event | Meaning |
|---|---|
op.AuditBCLNoSessionsForSubject | The caller named a session (/end_session with id_token_hint, or Provider.Logout against a session-bearing subject) but the fan-out resolved zero RPs. |
The event fires only when the caller actually named a session, so a Provider.Logout against a subject with no browser session does not generate noise. Under volatile session placement a miss is the OIDC Back-Channel Logout 1.0 §2.7 "best effort" floor; under durable placement it is an unexpected gap worth alerting on. The extras carry the configured op.SessionDurabilityPosture (SessionDurabilityVolatile or SessionDurabilityDurable) so SOC dashboards distinguish the two without keying on the store-adapter type.
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.