Back-Channel Logout (RP integration)
Source:
en/oauth/back-channel-logout.md· Live: https://docs.1pass.dev/en/oauth/back-channel-logout LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L31-L61: ## 1. Endpoint
- L62-L126: ## 2. logout_token verification recipe
- L127-L150: ## 3. 🔴 Which claim to match the user on —
subvscanonical_sub - L151-L168: ## 4. jti replay prevention — mark after the revoke succeeds
- L169-L188: ## 5. 🔴 WAF pitfall — exempt the callback path from bot-UA / IP blocking
- L189-L213: ## 6. Registering with logi + which events fire a BCL
- L214-L238: ## 7. Configuration contract (cross-service)
- L239-L264: ## 8. Verification (E2E) — fire a real POST
- L265-L273: ## 9. Reference implementations
Back-Channel Logout (RP integration)
When a user cancels a push approval on logi (the IdP), deletes their account, or disconnects a linked app — logi immediately force-terminates that RP's server session too, via OIDC Back-Channel Logout 1.0. To receive this signal, an RP just implements and registers one callback endpoint on its server.
This document covers how to implement the receiver on the RP (Relying Party) side. The logi producer is already complete, so you only need to add the RP code.
ℹ️ Note: When you need this For "undo" after account takeover / phishing to actually work, revoking the access token alone is not enough — if the RP holds its own session cookie / API token / device token, that session stays alive independently of logi. Back-Channel Logout closes that last piece (force-terminating the RP session).
1. Endpoint
The RP exposes one endpoint of the following shape.
POST {rp_origin}/<your-path>/backchannel-logout
Content-Type: application/x-www-form-urlencoded
logout_token=<RS256 JWT>- Server-to-server (S2S) call — the logi server POSTs directly, not a browser. There is no session cookie, CSRF token, or login state.
- Unauthenticated — there is no auth header. The only gate is the RS256 signature of the
logout_token. So you must verify it airtight (§2). - The body is a single form-urlencoded
logout_token. Not JSON. - The path is up to the RP. Reference implementations:
- ainote:
POST https://app.ainote.dev/auth/1pass/backchannel-logout - easybracket:
POST https://easybracket.org/api/v1/webhooks/logi/backchannel_logout
- ainote:
Response contract (OIDC BCL §2.8):
| Situation | Response |
|---|---|
| Verification success + session revoked | 200, empty body, Cache-Control: no-store |
| Verification success + no such user (unknown sub) | 200 (no-op) — prevents enumeration |
| jti replay (already processed) | 200 (no-op, no DB write) |
| Verification failure (signature/iss/aud/events/alg/nonce) | 400 |
If you return
404for an unknown sub, an attacker could probe "which sub exists at this RP" → always return 200.
2. logout_token verification recipe
logout_token is a JWT that logi signs with RS256. You must pass all of the following before trusting it.
header { alg: "RS256", kid: "<key id>", typ: "logout+jwt" }
payload {
iss: "https://api.1pass.dev", # logi OIDC_ISSUER
aud: "<your client_id>", # string or array
iat: 1750000000,
jti: "<uuid>", # replay-prevention key
sub: "<pairwise sub>", # standard claim (§3)
canonical_sub: "<canonical id>", # logi non-standard extra claim (§3)
events: { "http://schemas.openid.net/event/backchannel-logout": {} }
}Verification order:
- alg hard-pin — allow only
algorithms: ["RS256"]. This structurally blocksalg: none/HS256confusion attacks. Never rely on the JWT library's default alg inference. - JWKS key lookup — fetch the public key from
{issuer}/.well-known/jwks.jsonbykidand verify the signature.- Cache the JWKS (e.g. 1 hour). Ignore
jku(the key URL in the header) — use only the fixed URL under the issuer domain (prevents key-source spoofing). - Do not force a JWKS refetch even when an unknown
kidarrives. If it's not in the cache, just fail verification (400). This stops an attacker from spinning a refetch loop with arbitrary kids to DoS you. (Key rotation converges naturally within the cache TTL.)
- Cache the JWKS (e.g. 1 hour). Ignore
- iss —
verify_iss: true, expected value = the RP's issuer env (§7). It must be byte-identical to logi'sOIDC_ISSUER. - aud —
verify_aud: true, expected value = the RP's client_id.audmay be a string or an array, so use a "contains" check (most JWT libraries handle this automatically). If you have multiple client_ids (web/iOS/Android), use an allowlist (§7). - iat —
verify_iat: true+ a max-age check (e.g. reject a token older than 10 minutes). Blocks reuse of stale tokens. - typ — verify the header
typ == "logout+jwt". - events — verify that
eventsis an object and contains thehttp://schemas.openid.net/event/backchannel-logoutkey. (This is the "this is a logout token" marker.) - Reject nonce — reject if a
nonceclaim is present (BCL §2.6: a logout_token must not carry a nonce — blocks a token misidentified as an id_token). - Body size cap — put an upper bound (e.g. 8KB) on the
logout_tokenlength to reject oversized input.
⚠️ Warning: sub-only logout logi's logout_token carries only
sub, without asid(a specific session identifier). That means "terminate all sessions of this user." In a post-revoke / account-deletion context, this is the intended, safe behavior. If an RP wantssid-based single-session logout, that requires a separate agreement (currently unsupported).
Ruby reference implementation (ainote OnePassSsoService#verify_logout_token):
def verify_logout_token(logout_token)
raise SsoError, "logout_token required" if logout_token.blank?
ensure_https_issuer!
jwks = fetch_jwks # {issuer}/.well-known/jwks.json, 1h cache, jku ignored
claims, header = JWT.decode(
logout_token, nil, true,
algorithms: %w[RS256], # alg hard-pin
iss: issuer, verify_iss: true, # iss == ONE_PASS_URL
aud: client_id, verify_aud: true, # aud contains check
verify_iat: true,
required_claims: %w[iss aud iat jti sub events],
leeway: 60,
jwks: jwks
)
raise SsoError, "wrong typ" unless header["typ"] == "logout+jwt"
raise SsoError, "nonce forbidden" if claims.key?("nonce")
ev = claims["events"]
unless ev.is_a?(Hash) && ev["http://schemas.openid.net/event/backchannel-logout"].is_a?(Hash)
raise SsoError, "events malformed"
end
{ sub: claims["sub"], jti: claims["jti"] } # a canonical_sub RP returns canonical_sub
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
raise SsoError, "logout_token verification failed: #{e.class} #{e.message}"
end3. 🔴 Which claim to match the user on — sub vs canonical_sub
This is the most important decision in the RP integration. The logout_token carries two identity claims.
| Claim | Value | Nature |
|---|---|---|
sub | pairwise — a different opaque value per RP (client) | OIDC standard. Identical to the sub in the id_token / userinfo |
canonical_sub | logi's canonical user id — common across all clients, converges to the survivor on account merge | logi non-standard extra claim |
You must match on the same identity key the RP stored at login time.
💡 Tip: Decision guide
- If you stored
sub(pairwise) at login → match onsub. (ainote's approach:OauthProvider.uid = sub) - If you stored a canonical identifier (common across all clients) at login → match on
canonical_sub. (easybracket's approach:User.logi_user_sub = canonical_sub)
If you mix the two, they will never match and the session won't die. A pairwise sub differs per client, so looking it up in a canonical store is always a miss.
For logi's userinfo identity-claim policy, see Sub policy. If your RP uses canonical_sub, verify that the same canonical_sub value arrives on both login (userinfo) and logout (logout_token) — logi fills both identically from user.canonical_id.to_s.
🚨 Danger: Structural safeguard for a canonical RP The verification function of an RP that matches on canonical_sub should return only
canonical_suband never return/use the standardsub(pairwise). This blocks, at the code level, the accident of a caller mistakenly looking up by the pairwise sub. Add a regression test where a token that "has a pairwise sub but no canonical_sub" falls through to a match failure (no-op 200).
4. jti replay prevention — mark after the revoke succeeds
Record the logout_token's jti in a short-TTL cache (e.g. 10 minutes = the same as the iat max-age) to prevent reprocessing. The key point is the order.
1. verify_logout_token → sub/canonical_sub, jti
2. if jti is already in the cache → 200 (no-op, no DB write)
3. force_logout!(user) → revoke the session (must succeed)
4. ✅ record jti in the cache only after success ← always "after success"🚨 Danger: Don't mark jti before the revoke If you mark jti right after verification passes (before revoke), then even if the revoke step hits a DB error, the jti is already left as "processed." Then logi's retry gets swallowed by the idempotency guard, and the actual session never dies. Always mark after
force_logout!succeeds. (A fix that ainote's 6-lens + codex review converged on with high confidence.)
Use a dedicated cache key (e.g. logi_bcl_jti:<jti>) and do not mix it with the dedupe key of another webhook/event (the schema semantics differ).
5. 🔴 WAF pitfall — exempt the callback path from bot-UA / IP blocking
This is the hardest prod pitfall to catch. Code review will never catch it.
Many RPs put bot blocking in the application layer (Rack::Attack bad_user_agents blocklist, Cloudflare bot-fight, rate limiters, etc.). These usually block User-Agents like curl / python-requests / wget / go-http-client / bot / crawler with a 403.
logi's BackChannelLogoutJob is a server-to-server POST, and it can become a target of such blocking.
🚨 Danger: Real incident (ainote) ainote's
rack_attack.rbbad_user_agentsblocklist did not exempt the callback path. logi's HTTP client isNet::HTTP(which sets no User-Agent header), so it passed by accident (no default UA → no bad_agent match) — but it was a fragile state that would be silently blocked with 403 if logi switched to a library that attaches a UA (Faraday, etc.).
Hardening: explicitly exempt the callback path from the UA blocklist (the same category as /health, /.well-known/, etc.). Since the RS256 signature is the real gate, exempting the UA does not weaken security.
Checklist:
- Exempt the callback path from the UA blocklist (or place it under an already-verified S2S webhook path, e.g.
/api/v1/webhooks/...). - If you have a rate limiter, give the callback path a separate (lenient) throttle — normal traffic is one hit per revoke, but if it gets blocked, logout fails entirely.
- Always verify with a real POST E2E (§8). Code review (codex / multi-lens) cannot see the RP's WAF / Rack::Attack / rate-limit infra config. Whether the crypto passes but a 403 comes back instead of 200 only reveals itself when you actually fire a request.
6. Registering with logi + which events fire a BCL
The RP callback must be registered on logi's OauthApplication.backchannel_logout_uri to fire.
OauthApplication(client_id: "<your client_id>")
.backchannel_logout_uri = "https://<rp-host>/<your-path>/backchannel-logout"- An RP with an empty
backchannel_logout_uriis dormant — it only receives webhook (audit) signals, not BCL. Registration is reversible (set it back to nil to deactivate). - If you have multiple client_ids (web / iOS / Android), you must register a uri on each app that should receive BCL. Because canceling a push approval is essentially a web login scenario, registering on the web client alone is usually enough (server sessions are all revoked anyway via canonical matching — native is covered by just adding the trigger).
Which logi events fire a BCL:
| logi event | Fires BCL? |
|---|---|
| Push approval revoke (push-approval-revoke) | ✅ |
| Account deletion | ✅ |
| Linked app disconnect (app disconnect) | ✅ |
/oauth/revoke (RFC 7009 token revocation) | ❌ (audit webhook only) |
/oauth/revokedoes not fire a BCL. That's because it's the standard token-revocation endpoint, not a "terminate user session" signal.
7. Configuration contract (cross-service)
Before deploying, always verify that the values on both sides match. A mismatch is a footgun where every logout_token silently breaks with 400.
| Item | logi side | RP side |
|---|---|---|
| issuer | OIDC_ISSUER (= https://api.1pass.dev) | issuer env (ONE_PASS_URL / ONE_PASS_ISSUER) — byte-identical |
| aud | app client_id | client_id (allowlist env if multiple) |
| JWKS | {issuer}/.well-known/jwks.json (RS256) | fetch + cache from the same URL |
| events URI | http://schemas.openid.net/event/backchannel-logout | verify the same key |
| typ | logout+jwt | verify the same |
⚠️ Warning: issuer is an environment-variable contract — don't write it as a literal
issis a contract of "the env value logi issues == the env value the RP expects." Both prods must be explicitly set tohttps://api.1pass.dev. logi's issuer default is not a URL (the fallback whenOIDC_ISSUERis unset), so if the env is missing, every RP's BCL breaks on an iss mismatch. logi validates, with a fail-fast guard at production/staging boot, thatOIDC_ISSUERis a valid https URL. The RP should also explicitly set its own issuer env.
Multi client_id allowlist (easybracket pattern):
# aud verification: comma-separated env, falls back to a single client_id if unset
allowed = ENV.fetch("ONE_PASS_CLIENT_IDS", ENV["ONE_PASS_CLIENT_ID"]).split(",").map(&:strip)
# pass if any of the logout_token's aud (string|array) is in allowed8. Verification (E2E) — fire a real POST
Don't finish with crypto verification in code alone — POST a real logout_token to the RP and confirm it passes all the way through §5 (WAF).
Issue a real token on the logi server and send it to the RP callback:
# issue a real logout_token with a logi rails runner (a throwaway user is recommended)
tok=$(... Oauth::LogoutTokenIssuer.issue(user: <user>, oauth_application: <rp_app>) ...)
# POST to the RP callback
curl -X POST https://<rp-host>/<your-path>/backchannel-logout \
-H "User-Agent;" \
--data-urlencode "logout_token=$tok"
# 200 empty body = crypto (JWKS+RS256+iss/aud/events) + WAF all pass
# 400 = verification/contract failure
# 403 = WAF block (§5 — missing callback-path exempt)⚠️ Warning: Watch for side effects If the user you issued for has an active session at the RP, that session actually gets logged out once (recoverable). If you want a fully non-disruptive check, issue for a throwaway user.
Be sure to add negative tests too: alg=none / HS256 / aud mismatch / missing jti / missing (or expired) iat / nonce present / malformed events / (for a canonical RP) pairwise-sub-present-but-canonical-absent → all rejected (400 or no-op). jti replay → the second request is 200 but with no session re-revoke / second write.
9. Reference implementations
| RP | Matching method | Endpoint | Receiver code |
|---|---|---|---|
| ainote | pairwise sub (OauthProvider.uid) | POST /auth/1pass/backchannel-logout | Auth::OnePassBackchannelLogoutController + OnePassSsoService#verify_logout_token |
| easybracket | canonical_sub (User.logi_user_sub) | POST /api/v1/webhooks/logi/backchannel_logout | Api::V1::Webhooks::LogiController#backchannel_logout + OnePassSsoService#verify_logout_token (aud allowlist) |
Related docs: Sub policy (sub / canonical_sub) · Recommended architecture (RP integration) · Token Introspection & JWKS