ZITADEL is a self-hosted identity platform: it issues the OAuth and OIDC tokens that other applications trust. The token endpoint, POST /oauth/v2/token, is where grants become tokens, which makes it the part of the system with the least room for an authorization gap.
We reported a systemic one there. On every grant redemption, ZITADEL authenticated the client making the request, but never checked that the grant being redeemed had been issued to that client. The new access, ID, and refresh tokens were built from the grant’s stored session, the original user’s identity and the original audience, and handed back to whichever client made the call. Reported to ZITADEL and fixed in 4.15.2 and 3.4.12 (CVE-2026-55672, CWE-287 / CWE-863, High, CVSS 7.4). It was independently reported by several researchers; this writeup reflects our own analysis, confirmed against a stock instance from raw token-endpoint requests rather than from the code alone.
IMPORTANT
Who’s exposed. You’re affected if you run ZITADEL as your OAuth/OIDC provider on an affected release (4.0.0 to 4.15.1, or 3.0.0 to 3.4.11; through 4.15.2 / 3.4.12 for the token-exchange variant) and more than one client, or more than one tenant, trusts the tokens it issues. A grant artifact issued to one client (a refresh token, authorization code, device code, or a token presented for exchange) can be redeemed by a different client, which then receives tokens minted for the original user. You’re not affected once you upgrade to 4.15.2 / 3.4.12 (and 4.15.3 for token exchange), or if a single client is the only consumer of the instance. Public and native apps are protected on the authorization-code path by PKCE.
The tell: the check existed, on a different path
The most useful signal in this finding is that the binding check was not missing from the codebase. It was already written, and already used, one method over.
ZITADEL’s session model carries a CheckClient method that asserts a client is in the session’s audience:
// internal/command/oidc_session_model.go
func (wm *OIDCSessionWriteModel) CheckClient(clientID string) error {
for _, aud := range wm.Audience {
if aud == clientID { return nil }
}
return zerrors.ThrowPreconditionFailed(nil, "OIDCS-SKjl3", "Errors.OIDCSession.InvalidClient")
}
The revoke path called it: you cannot revoke a token that was not issued to you. None of the redemption paths did: grep -rn "CheckClient" internal/ returns exactly two hits, the definition above and that single revoke call site. The same operation that was carefully bound to its client when destroying a token was completely unbound when minting one. The asymmetry is the vulnerability: not a wrong check, a check applied on one path and forgotten on its siblings.
NOTE
This is why the bug is invisible to a scanner. There is no dangerous function call and no tainted input flowing to a sink. The token endpoint authenticates a client correctly and reads a session correctly. The defect is a relationship that was never asserted, between the authenticated client and the grant it is redeeming, and the proof that it was an oversight rather than a design choice is that the very same assertion lives on the revoke path.
What it reached, across three grants
Because the gap was in the shared redemption logic rather than in any one handler, it surfaced through every grant type that redeems against a stored session. The public advisory covers three:
- Refresh token cross-use. Any client could redeem another client’s refresh token. ZITADEL authenticated the requester, then minted tokens from the original user’s session and returned them to the requester. Redemption also rotates the refresh token, so a successful redemption simultaneously locked the legitimate client out of its own session. RFC 6749 §6 only permits a refresh token to be redeemed by the client it was issued to; that comparison was the one never made.
- Authorization code injection. A confidential client could exchange another confidential client’s authorization code where PKCE was not in play. The code-exchange checks validated PKCE and
redirect_uribut never that the code had been issued to the client now presenting it. (Public and native apps are protected here: PKCE binds the exchange to the initiator, and ZITADEL forces PKCE for those clients.) - Device authorization cross-use. A client that did not start a device flow could redeem another client’s
device_codeand receive the approving user’s tokens. RFC 8628 requires the token request to come from the client that began the flow; that comparison was not made.
In each case the minted token asserts the original user as its subject and is accepted by the resource servers that trust ZITADEL, because as far as they can tell it is a legitimately issued token.
Why it crosses tenants
ZITADEL is multi-tenant: organizations are isolation boundaries. The redeeming client does not have to live in the same organization as the grant it redeems. As the advisory notes, on a shared instance a client belonging to one tenant could redeem a code or token belonging to another tenant’s client if that artifact is intercepted, and the minted token, carrying the victim tenant’s audience, is then honored by the victim tenant’s resource servers.
For a multi-tenant identity provider, tenant isolation is the core promise. A redemption path that does not bind the grant to its client erodes that promise wherever a grant artifact can leak, which for public single-page and mobile clients is a realistic place for refresh tokens and codes to end up.
A fourth flow, now public: token exchange
The same gap reached one more redemption-shaped path, disclosed separately once its own fix shipped. OAuth 2.0 Token Exchange (RFC 8693) lets a client present a token it already holds and ask ZITADEL to mint a new one. It is the mechanism services use to delegate and impersonate across trust boundaries. The exchange authenticated the client making the request, but never checked that the access token being exchanged was bound to, or authorized for, that client. A client or user holding only a low-privilege token could exchange it for tokens carrying elevated permissions at a different application, stepping over the authorization and separation boundaries the exchange exists to enforce.
It is the redemption asymmetry one layer up: redeem is unbound, and so is exchange. Reported to ZITADEL and fixed in 4.15.3 (CVE-2026-56668, High, CVSS 8.1). The same guard closes it: assert that the token presented for exchange was issued to the authenticated client before minting anything from it.
Remediation
One guard closes the whole class. Re-assert, in the command layer, that the grant being redeemed was issued to the authenticated client, reusing the audience-membership check the revoke path already relies on, on every redemption path: refresh token, authorization code, device authorization, and token exchange.
// enforce before minting, on each redemption path
if err := writeModel.CheckClient(authenticatedClientID); err != nil {
return err // the same assertion revoke already makes
}
As defense in depth, sender-constraining tokens for public clients (DPoP or mTLS, RFC 9449 / RFC 8705) means a leaked refresh token cannot be replayed by a different client even if a binding check is ever missed again. And the regression test that would have caught this is the one the suite did not have: redeem each grant as a different client than the one it was issued to, and require it to fail.
Disclosure
- Software: ZITADEL 4.0.0 to 4.15.1 and 3.0.0 to 3.4.11 (grant redemption); the token-exchange variant reaches through 4.15.2 / 3.4.12.
- Class: CWE-287 / CWE-863, missing client binding on grant redemption.
- Severity: High. Redemption CVSS 7.4; token exchange CVSS 8.1.
- CVE: CVE-2026-55672 (redemption) and CVE-2026-56668 (token exchange).
- Fix: 4.15.2 / 3.4.12 for redemption, 4.15.3 for token exchange. Reported to ZITADEL; independently reported by several researchers.
- Reported by: Cipher / Causal Security.
Confirmed against a stock ZITADEL instance from raw token-endpoint requests; this writeup reflects our own analysis.
Why we look at flows, not files
Reasoning about a single file would not have found this. The token endpoint reads as correct in isolation: it authenticates the client, loads the session, checks scope, mints the token. The missing step only becomes visible when you hold the whole grant lifecycle in view at once and notice that issue, redeem, and revoke make different assumptions about who is allowed to act on a grant. Revoke is bound; redeem is not. Seeing that asymmetry is the work, and it is the same kind of cross-cutting reasoning that surfaces business-logic chains elsewhere: the bug lives in the relationship between operations, not in any one of them.
This work sits in our API security practice and is part of our ongoing vulnerability research into the open-source infrastructure that everyone else builds on.