Skip to content

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
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 events claim slots an event-type key (here http://schemas.openid.net/event/backchannel-logout) so a generic SET receiver can dispatch to the right handler.

Source: examples/42-back-channel-logout

Architecture

Back-channel logout sequence: RP A drives /end_session, the OP terminates the session and fans out signed logout tokens to RP B and RP C, then redirects RP A.UserRP AinitiatesOPRP BRP Cclick “log out”1redirect to/end_session?id_token_hint=…2terminate session3fan-out to every RP in the sessionPOST backchannel_logout_uri · logout_token = signed JWT4520062007302 post_logout_redirect_uri8

The OP signs a logout_token per RP and POSTs it to that RP's backchannel_logout_uri. The token contains:

ClaimMeaning
issOP issuer
audThe RP's client_id
iat, jtiIssuance time + replay nonce
subWhose 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:

go
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:

go
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:

go
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:

go
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:

BoundDefaultWhat it caps
Deduplicated audienceDefaultMaxTargets (256)clients notified for one logout; the grant query itself is capped, not filtered afterwards
Concurrent deliveriesDefaultMaxConcurrentDeliveries (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:

EventMeaning
op.AuditBCLNoSessionsForSubjectThe 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.