Skip to content

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 — sub vs canonical_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.

text
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

Response contract (OIDC BCL §2.8):

SituationResponse
Verification success + session revoked200, 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 404 for 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.

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

  1. alg hard-pin — allow only algorithms: ["RS256"]. This structurally blocks alg: none / HS256 confusion attacks. Never rely on the JWT library's default alg inference.
  2. JWKS key lookup — fetch the public key from {issuer}/.well-known/jwks.json by kid and 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 kid arrives. 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.)
  3. issverify_iss: true, expected value = the RP's issuer env (§7). It must be byte-identical to logi's OIDC_ISSUER.
  4. audverify_aud: true, expected value = the RP's client_id. aud may 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).
  5. iatverify_iat: true + a max-age check (e.g. reject a token older than 10 minutes). Blocks reuse of stale tokens.
  6. typ — verify the header typ == "logout+jwt".
  7. events — verify that events is an object and contains the http://schemas.openid.net/event/backchannel-logout key. (This is the "this is a logout token" marker.)
  8. Reject noncereject if a nonce claim is present (BCL §2.6: a logout_token must not carry a nonce — blocks a token misidentified as an id_token).
  9. Body size cap — put an upper bound (e.g. 8KB) on the logout_token length to reject oversized input.

⚠️ Warning: sub-only logout logi's logout_token carries only sub, without a sid (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 wants sid-based single-session logout, that requires a separate agreement (currently unsupported).

Ruby reference implementation (ainote OnePassSsoService#verify_logout_token):

ruby
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}"
end

3. 🔴 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.

ClaimValueNature
subpairwise — a different opaque value per RP (client)OIDC standard. Identical to the sub in the id_token / userinfo
canonical_sublogi's canonical user id — common across all clients, converges to the survivor on account mergelogi 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 on sub. (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_sub and never return/use the standard sub (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.

text
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.rb bad_user_agents blocklist did not exempt the callback path. logi's HTTP client is Net::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.

text
OauthApplication(client_id: "<your client_id>")
  .backchannel_logout_uri = "https://<rp-host>/<your-path>/backchannel-logout"
  • An RP with an empty backchannel_logout_uri is 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 eventFires BCL?
Push approval revoke (push-approval-revoke)
Account deletion
Linked app disconnect (app disconnect)
/oauth/revoke (RFC 7009 token revocation)❌ (audit webhook only)

/oauth/revoke does 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.

Itemlogi sideRP side
issuerOIDC_ISSUER (= https://api.1pass.dev)issuer env (ONE_PASS_URL / ONE_PASS_ISSUER) — byte-identical
audapp client_idclient_id (allowlist env if multiple)
JWKS{issuer}/.well-known/jwks.json (RS256)fetch + cache from the same URL
events URIhttp://schemas.openid.net/event/backchannel-logoutverify the same key
typlogout+jwtverify the same

⚠️ Warning: issuer is an environment-variable contract — don't write it as a literal iss is a contract of "the env value logi issues == the env value the RP expects." Both prods must be explicitly set to https://api.1pass.dev. logi's issuer default is not a URL (the fallback when OIDC_ISSUER is 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, that OIDC_ISSUER is a valid https URL. The RP should also explicitly set its own issuer env.

Multi client_id allowlist (easybracket pattern):

ruby
# 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 allowed

8. 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:

bash
# 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

RPMatching methodEndpointReceiver code
ainotepairwise sub (OauthProvider.uid)POST /auth/1pass/backchannel-logoutAuth::OnePassBackchannelLogoutController + OnePassSsoService#verify_logout_token
easybracketcanonical_sub (User.logi_user_sub)POST /api/v1/webhooks/logi/backchannel_logoutApi::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

최종 수정:

Identity가 제품의 신뢰를 만듭니다.