Skip to main content
When theAuth is the OIDC provider, a user who signs out should be signed out of the apps that used it too. Two mechanisms do that, and both are opt in:
  • RP-initiated logout. A relying party sends the browser to your end_session_endpoint. You end the provider session and redirect back.
  • Back-channel logout. When a provider session ends for any reason, theAuth posts a signed logout token to each relying party’s backchannel_logout_uri, server to server.
Front-channel logout is not implemented.

What turning it on changes

Pass logout to createOidcProviderModule. Then:
  • Discovery gains end_session_endpoint, backchannel_logout_supported and backchannel_logout_session_supported.
  • ID tokens carry a sid claim.
  • provider.logout exists, and clients can be registered with postLogoutRedirectUris and backchannelLogoutUri.
  • Each authorization is recorded in a sid index (theauth_oidc_sessions) so a later logout knows which relying parties to notify. Client logout settings live in theauth_oidc_logout_clients.

Wire it up

There is a loop to untangle. The provider needs a session manager and database, and the session manager’s onRevoke needs the provider. A late-bound reference breaks it:
oidcLogout(provider) mounts GET and POST at logout.endSessionPath (rate limited to 30 per minute). It throws at startup if the provider was created without logout. Because onRevoke is on the session config, every revoke fans out: a user signing out, revokeAll after a password reset, or an eviction by maxConcurrent.
The provider tables (theauth_oidc_clients, theauth_oidc_logout_clients, theauth_oidc_sessions and friends) are only created by createTheAuth when a plugin with the id theauth-oidc-provider is registered, and no built-in plugin has that id. Run createTables yourself, as above, or create the tables with your own migration. With sqlite, use one database handle instead of two, because createDatabase loads the file into memory per handle.
The route is registered under your adapter prefix, but discovery advertises ${issuer}${endSessionPath}. If you mount theAuth at /api/auth, either serve the endpoint at the issuer root with a rewrite or set endSessionPath to match, so the advertised URL resolves.
Pass the provider session’s id to authorize so the authorization is tied to it:
The sid is derived from the session id (deriveSid(issuer, sessionId)), so revoking that session finds every relying party that holds it. Without sessionId the sid is random. Logout still works when the relying party supplies id_token_hint or you end all sessions for a user, but revoking a specific session cannot find the authorization.

Register a client

Passing either logout field without config.logout fails with LOGOUT_NOT_ENABLED. Post-logout URIs are an exact-match allowlist. The back-channel URI must be https unless allowInsecureBackchannel is on. To change them later, call provider.logout.setClientLogoutConfig(clientId, { postLogoutRedirectUris, backchannelLogoutUri }).

The end session endpoint

The relying party redirects the browser to end_session_endpoint with: It revokes the session named by the request cookie (only if it belongs to the hinted user) and any session the sid maps to, notifies relying parties, runs onEndSession, then answers 302 to the post-logout URI, else defaultRedirectUri, else 200.

Back-channel fan-out

For each relying party with a back-channel URI, theAuth signs a logout token with the provider key and posts it as application/x-www-form-urlencoded (logout_token=...). The token has iss, aud (the client id), sub, sid, jti, iat and the back-channel logout event, and no nonce. Delivery refuses non-https URLs and hosts that resolve to private addresses, never follows redirects, and times out after backchannelTimeoutMs. One token goes to each client per sid and user, in parallel. You can also trigger it directly. Both return { attempted, delivered, failed } and never throw:

Limits

  • There is no retry. The sid index rows are claimed (deleted) before delivery. A relying party that is down, times out or returns an error does not get the token again. It shows up in failed and nowhere else, so log it if you care. The relying party’s own session then lives until it expires. Pair back-channel logout with short relying-party sessions.
  • Delivery runs inline. onRevoke is awaited, so a slow relying party delays the revoke that triggered it by up to the timeout.
  • Index rows expire. After indexTtlSeconds an old authorization can no longer be found, and ending that session notifies nobody.
  • Local logout always completes. A fan-out failure is swallowed, never blocks revocation.

Receive logout tokens (relying party)

An app that signs users in with an OIDC provider, including another theAuth, can accept back-channel logout with createBackChannelLogoutReceiver:
Record the provider’s sid on the local session when the user signs in, so a sid can be matched:
How a token maps to local sessions: Customize with sessionRevoker: { resolveUserId, findSessionsBySid, sidMetadataKey }, or pass your own revokeSessions(claims). onLogout(claims) still runs after revocation if you set it. The endpoint never answers 200 when nothing is configured to sign the user out. The receiver checks issuer, audience, signature (RS256, ES256 or EdDSA by default), that the token is at most 120 seconds old (maxAgeSeconds), the back-channel event, no nonce, and a jti it has not seen. The default replay store is per process, so give every instance a shared replayStore (markSeen(jti, ttlSeconds)) if you run more than one. If you use stateless sessions instead, see Session hardening for pairing the receiver with isRevoked.

OIDC provider

Run theAuth as an OpenID Connect provider.

Sign-in gate

Session limits and the onRevoke hook.

Session hardening

Stateless sessions and federated logout.
Last modified on October 13, 2026