Revoking Tokens on Logout in a BFF
A “logout” button that only deletes a cookie is a security illusion: the refresh token is still live at the identity provider, the access token still passes introspection, and the OIDC session at the IdP still silently re-authenticates the next visit. This page — part of the OAuth 2.0 Token Revocation Best Practices guide — walks through doing logout correctly in a Backend-for-Frontend (BFF): actively revoking both tokens via the revocation endpoint (RFC 7009), tearing down the server session and its HttpOnly cookie, and propagating the signout to the IdP with RP-initiated logout. The BFF pattern is the right place for this because the browser never holds the tokens — they live server-side in the BFF session for SPAs, so logout becomes a server-controlled, auditable operation rather than a hopeful client-side gesture.
What a Correct BFF Logout Must Do
A complete logout has four distinct teardown targets, and skipping any one of them leaves a residual path to authenticated access:
- The refresh token — revoke it at the IdP (RFC 7009) so no new access tokens can be minted from it.
- The access token — revoke it too where the IdP supports it, and stop sending it; otherwise it remains valid until
exp. - The local session — destroy the server-side session record and expire the HttpOnly cookie so the BFF forgets the user.
- The IdP session — trigger RP-initiated logout (
end_session_endpoint) so the IdP’s own SSO cookie doesn’t silently log the user back in.
The BFF Logout Sequence
sequenceDiagram
participant B as Browser
participant F as BFF Server
participant S as Session Store
participant I as IdP
B->>F: POST /logout (cookie + CSRF token)
F->>S: Load session, read RT and AT
F->>I: POST /revoke (refresh_token) [RFC 7009]
I-->>F: 200 OK
F->>I: POST /revoke (access_token)
I-->>F: 200 OK
F->>S: Destroy session record
F-->>B: Set-Cookie: sid=; Max-Age=0
F-->>B: 302 → IdP end_session_endpoint
B->>I: GET /end_session?id_token_hint=...&post_logout_redirect_uri=...
I-->>B: Clears SSO cookie, 302 → app
The order matters: revoke before you destroy the session (you need the tokens), and redirect to the IdP last so the browser carries the id_token_hint for RP-initiated logout.
Implementation
1. The revocation calls (RFC 7009)
The revocation endpoint takes the token and an optional token_type_hint. Authenticate the client (these are confidential-client credentials held only by the BFF). Per RFC 7009 §2.2, the endpoint returns 200 even for an unknown or already-revoked token, so treat non-200 as a transient error to retry, not as “user is still logged in.”
import { discoveryRequest, processDiscoveryResponse } from "oauth4webapi";
interface TokenSet { accessToken: string; refreshToken?: string; idToken: string; }
async function revokeToken(
cfg: { revocationEndpoint: string; clientId: string; clientSecret: string },
token: string,
hint: "refresh_token" | "access_token",
): Promise<void> {
const body = new URLSearchParams({ token, token_type_hint: hint });
const auth = "Basic " +
Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString("base64");
const res = await fetch(cfg.revocationEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: auth },
body,
});
// RFC 7009 §2.2: 200 even for already-invalid tokens. 503 = retry later.
if (res.status !== 200) {
throw new Error(`Revocation failed (${res.status}) for ${hint}`);
}
}
Revoking the refresh token is the critical one: RFC 7009 §2.1 notes that revoking a refresh token SHOULD also invalidate access tokens derived from it (IdP-dependent). Revoke the access token explicitly as well so introspection-based resource servers reject it immediately rather than waiting out its TTL.
2. The logout route
import type { Request, Response } from "express";
export async function logoutHandler(req: Request, res: Response) {
// Logout MUST be a state-changing POST guarded by CSRF (RFC 9700 §4.4.1.13)
// so an attacker cannot force-logout via a cross-site GET.
if (req.method !== "POST" || !verifyCsrf(req)) {
return res.status(403).end();
}
const session = await sessionStore.get(req.cookies.sid);
const idTokenHint = session?.tokens.idToken;
if (session) {
const { refreshToken, accessToken } = session.tokens;
// Revoke in parallel; tolerate individual failures so we still clear locally.
await Promise.allSettled([
refreshToken && revokeToken(idpCfg, refreshToken, "refresh_token"),
accessToken && revokeToken(idpCfg, accessToken, "access_token"),
]);
await sessionStore.destroy(req.cookies.sid); // server-side teardown
}
// Expire the cookie with the SAME attributes it was set with, or it won't clear.
res.cookie("sid", "", {
httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 0,
});
// RP-initiated logout: hand the IdP an id_token_hint and a registered return URL.
const endSession = new URL(idpCfg.endSessionEndpoint);
if (idTokenHint) endSession.searchParams.set("id_token_hint", idTokenHint);
endSession.searchParams.set("post_logout_redirect_uri", "https://app.example.com/");
endSession.searchParams.set("client_id", idpCfg.clientId);
return res.redirect(302, endSession.toString());
}
3. RP-initiated logout details
The end_session_endpoint comes from the IdP’s discovery document (/.well-known/openid-configuration). The OpenID Connect RP-Initiated Logout spec requires:
id_token_hint— the ID token from login, so the IdP knows which session to end. Without it, some IdPs render an interactive “are you sure?” page, breaking the flow.post_logout_redirect_uri— must exactly match a value pre-registered with the IdP, or the redirect is rejected as an open-redirect risk.
Never reuse a logged-out ID token for a new login; keep it only long enough to pass it as the hint.
4. Front-channel vs back-channel logout
A single BFF logout doesn’t reach other applications sharing the same IdP session. For multi-app SSO, the IdP propagates logout via one of two OIDC mechanisms:
| Mechanism | How it works | Trade-off |
|---|---|---|
| Front-channel logout | IdP renders hidden <iframe>s pointing at each RP’s logout URL; the browser clears each cookie. |
Simple, but fails if third-party cookies are blocked or any RP iframe stalls. |
| Back-channel logout | IdP sends a signed logout_token (a JWT, RFC 7519) server-to-server to each RP’s back-channel endpoint. |
Robust, cookie-independent, but each RP must implement a verified endpoint. |
Prefer back-channel logout in production: it survives third-party-cookie deprecation. Your BFF’s back-channel endpoint must validate the logout_token signature against the IdP JWKS with an explicit allowlist, confirm the events claim, and match sub/sid to a live session before destroying it.
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL(idpCfg.jwksUri));
export async function backchannelLogout(req: Request, res: Response) {
const logoutToken = req.body.logout_token as string;
const { payload } = await jwtVerify(logoutToken, JWKS, {
issuer: idpCfg.issuer,
audience: idpCfg.clientId,
algorithms: ["RS256"], // explicit allowlist — never accept "none" or HS256 here
});
// Must be a logout token, never an ID token (OIDC Back-Channel Logout §2.4).
const events = payload.events as Record<string, unknown> | undefined;
if (!events?.["http://schemas.openid.net/event/backchannel-logout"]) {
return res.status(400).end();
}
if (payload.nonce) return res.status(400).end(); // logout tokens MUST NOT carry nonce
await sessionStore.destroyBySid(payload.sid as string);
return res.status(200).end();
}
Security Implications
- Logout must be CSRF-protected. A force-logout via a cross-site GET is a denial-of-service and a session-fixation setup. Require POST plus a double-submit or synchronizer CSRF token (RFC 9700 §4.4).
- Revoke before you forget. If you destroy the session first, you lose the tokens and can no longer revoke them — they stay live until
exp. Read the tokens, revoke, then destroy. - Clear the cookie with matching attributes. A
Set-CookiewithMax-Age=0only clears the cookie ifpath,domain, andsecurematch the original (RFC 6265). Mismatched attributes leave a stale cookie behind. - Don’t trust the IdP to cascade. Some IdPs invalidate access tokens when the refresh token is revoked; many don’t. Revoke both explicitly, and for introspection-based resource servers add the token’s
jtito a denylist until itsexppasses. - Validate the back-channel token strictly. Pin the algorithm allowlist (
RS256/ES256), rejectalg: none, verifyiss/aud, and confirm the back-channeleventsclaim — a forged logout token is a targeted DoS vector.
Prevention & Monitoring Hooks
- Log every logout as a security event with
sub,sid, revocation results, and the trigger (user action vs back-channel). A spike in failed revocations means your IdP integration is degraded and sessions are not being killed. - Alert on revocation
5xxrates — if the revocation endpoint is unreachable, users believe they logged out while tokens stay live. Queue failed revocations for retry rather than dropping them. - Test the full chain in CI: simulate logout, then assert the refresh token returns
invalid_grantat/token, the access token returnsactive: falseat/introspect, and the cookie is expired. - Audit
post_logout_redirect_uriregistrations quarterly to retire stale URLs and prevent open-redirect drift.
Frequently Asked Questions
Is clearing the session cookie enough to log a user out?
No. Clearing the cookie only makes the browser forget the session id. The refresh token can still mint new access tokens at the IdP, any outstanding access token still passes introspection until exp, and the IdP’s own SSO cookie will silently re-authenticate on the next login. A real logout revokes the tokens (RFC 7009), destroys the server-side session, and triggers RP-initiated logout at the IdP.
The revocation endpoint returns 200 even for tokens I never issued — is that a bug?
That’s by design. RFC 7009 §2.2 mandates a 200 response for invalid, expired, or already-revoked tokens so attackers can’t probe token validity through the revocation endpoint. Treat any non-200 (typically 503) as a transient failure to retry — not as “the token is still valid.”
Do I need front-channel and back-channel logout if I only have one app?
No. Front-channel and back-channel logout exist to propagate signout across multiple RPs that share one IdP session. With a single BFF app, RP-initiated logout to the end_session_endpoint plus your local token revocation and session teardown is sufficient. Add back-channel logout when a second application joins the same SSO realm.
Why revoke the access token if it expires in a few minutes anyway?
Because “a few minutes” of valid access after logout is still a real window — long enough for an attacker holding an exfiltrated access token to act, and a compliance gap for SOC 2 / HIPAA session-termination requirements. Revoking it (or denylisting its jti until exp) makes introspection-based resource servers reject it immediately. Stateless JWT resource servers that don’t introspect need the short TTL plus a denylist to close the gap.
Related
- OAuth 2.0 token revocation best practices — the parent guide on revocation endpoints, introspection, and denylisting.
- Detecting refresh token reuse with rotation — tearing down a token family when a stolen refresh token is replayed.
- Mitigating CSRF attacks in modern SPAs — protecting the logout POST from cross-site forgery.