ASVS 5.0 V10.5.5 (Level 2) applies when your app, as an OIDC relying party, supports back-channel logout. Before acting on a logout token, you must verify that its type header is logout+jwt, that it contains the events claim with the correct member name, and that it does not contain a nonce claim. A short expiry, around 2 minutes, is also recommended.
The requirement text calls the claim event, while the OpenID Back-Channel Logout specification names it events, with a member http://schemas.openid.net/event/backchannel-logout. Follow the specification when you code it.
Why it matters
Back-channel logout means the provider posts a signed JWT to an endpoint on your server, and your server ends the named session. That endpoint accepts requests from the internet, so two attacks matter.
First, forced logout: an attacker who can get any signed token accepted would sign users out at will, a denial of service. Second, cross-JWT confusion: an ID Token or access token from the same provider is also signed by the same key. If your endpoint accepts any valid JWT, an attacker can replay an ordinary ID Token as a logout token. The strict type check, the required events claim and the banned nonce claim are what tell a logout token apart from an ID Token. ID Tokens may carry a nonce, logout tokens must not.
How to implement it
Validate in this order:
LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
def handle_backchannel_logout(raw_token):
header = jwt.get_unverified_header(raw_token)
if header.get("typ") != "logout+jwt":
raise Reject()
claims = jwt.decode(
raw_token,
key=jwks_key_for(header),
algorithms=["RS256", "ES256"],
audience=CLIENT_ID,
issuer=EXPECTED_ISSUER,
options={"require": ["exp", "iat", "jti", "iss", "aud"]},
)
if "nonce" in claims:
raise Reject()
events = claims.get("events")
if not isinstance(events, dict) or LOGOUT_EVENT not in events:
raise Reject()
if "sub" not in claims and "sid" not in claims:
raise Reject()
if abs(time.time() - claims["iat"]) > 120:
raise Reject()
if not jti_cache.add_if_new(claims["jti"], ttl=300):
raise Reject()
end_sessions(issuer=claims["iss"], sub=claims.get("sub"), sid=claims.get("sid"))
Expect a POST with application/x-www-form-urlencoded and a logout_token field, and answer 200 on success or 400 on a bad token. Only end sessions that belong to the issuer in the token.
Operational notes
Publish the endpoint URL in your client registration as the back-channel logout URI, and make it an https URL. Do not require a session cookie on it, because the provider is not a browser. Protect it with the token checks instead, and keep the response small and constant so the endpoint tells a caller nothing about your sessions.
Store a mapping from the provider's sid and sub to your own sessions at login time. Without it, you can verify a logout token perfectly and still have nothing to end. Make session termination idempotent, since providers may retry.
Rate limit the endpoint per source. Even with strict validation, a flood of garbage requests costs signature verifications, and a limit keeps that cost bounded.
How to test it
Feed the endpoint each of these and expect rejection:
- A genuine ID Token from the same provider.
- A logout token with
typset toJWTor missing. - A logout token with a
nonceclaim. - One missing the
eventsclaim, or with a wrong member name. - One with an
iatolder than a few minutes. - The same valid token submitted twice.
- One signed by a different key, or with the wrong audience.
Then send a valid token and confirm the right session ends and others stay alive.
Automating the check
Build a helper that mints test tokens with chosen headers and claims, signed by a test key your client trusts. Then run a table of negative cases, one defect per case, and assert HTTP 400 for each. One defect per case matters: if you change two things at once, a failure does not prove either check works.
What the spec calls things
The ASVS text and the OpenID specification differ slightly in wording, so make sure your tests use the specification's names. The claim is events, a JSON object. The member name is the URI shown above, and its value is an empty JSON object. The token must carry sub, sid, or both. If it carries only sid, end that session only. If it carries only sub, end the user's sessions with that provider, according to your own policy.
Document the choice in your design notes. Reviewers will want to know whether a single logout token can end every session a user has, because that is the exact power an attacker is trying to get.
Why the details are strict
Every rule in this check closes a specific hole. The type header stops a signed token of another kind from being accepted. The missing nonce rule stops ID Tokens from passing. The events claim proves the sender meant a logout. The short lifetime and replay cache limit what a captured token can do. Dropping any one leaves an attacker a way in, so test them one at a time.
Common mistakes
- Reusing the ID Token validation function without the extra checks.
- Not checking
typ, because some libraries do not surface it by default. - Ending every session for a user when the token named a single
sid. - Leaving the endpoint without a replay cache.
Quick check
Why is a nonce claim a reason to reject a logout token?
ID Tokens may contain a nonce and logout tokens must not, so its presence suggests an ID Token is being passed off as a logout token.
What attack does the type check prevent?
Cross-JWT confusion, where a differently purposed token signed by the same provider key is accepted as a logout request.