jwt-bearer grant returns 500 server_error (Errors.Internal) for any malformed assertion instead of 400 invalid_grant
Preconditions
- ZITADEL v4.10.1, self-hosted (container
ghcr.io/zitadel/zitadel:v4.10.1) - Postgres storage
- Any instance with the JWT-profile grant reachable at
/oauth/v2/token
Describe the bug
POST /oauth/v2/token with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer returns
500 with {"error":"server_error","error_description":"Errors.Internal"} for every
malformed or unverifiable assertion I could construct — including input that is not a JWT at all.
RFC 7523 §3 is explicit that a JWT which fails validation is a client error:
If the JWT is not valid, [...] the authorization server constructs an error response as defined in OAuth 2.0 [RFC6749]. The value of the "error" parameter MUST be the "invalid_grant" error code.
So the expected response is 400 invalid_grant, not 500 server_error.
The 500 appears to be terminal rather than transient: the same request returns the same 500 consistently, and the instance is otherwise healthy (the authorization code and refresh token grants work normally against the same endpoint at the same time).
Steps to Reproduce
All three of these are unauthenticated and need no registered application:
# 1. not a JWT at all
curl -i -X POST "https://<instance>/oauth/v2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion=not-a-jwt" \
--data-urlencode "scope=openid"
# 2. structurally valid JWT, self-signed HS256, unknown issuer
curl -i -X POST "https://<instance>/oauth/v2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4Iiwic3ViIjoieCIsImF1ZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
--data-urlencode "scope=openid"
# 3. same, but alg=RS256
curl -i -X POST "https://<instance>/oauth/v2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4Iiwic3ViIjoieCIsImF1ZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
--data-urlencode "scope=openid"Screenshots / response
Identical for all three:
HTTP/2 500
content-type: application/json
{"error":"server_error","error_description":"Errors.Internal"}Expected behavior
HTTP/2 400
{"error":"invalid_grant","error_description":"..."}Cases 2 and 3 (a well-formed JWT that simply does not verify against any registered key)
in particular look like ordinary invalid_grant territory rather than an internal fault.
Why this matters in practice
Not a security issue on its own — no credential is accepted and nothing leaks; the
error_description is a constant. It is an operational one:
- It is an unauthenticated 5xx. Anyone who can reach the token endpoint can drive the instance's server-error rate with a one-line curl. Any alert or SLO built on 5xx rate (which is the conventional way to alert on an IdP) can be pushed to fire by an outside caller, so the signal has to be qualified or ignored.
- Clients cannot make the right retry decision.
server_erroris the class a client is supposed to retry and back off against;invalid_grantis the class it must not. Returning the former for a permanent, caller-side condition means a well-behaved client retries a request that can never succeed. - It hides real faults. Genuine internal errors on this endpoint are indistinguishable from malformed input, so the one log line that should mean "something is broken" does not.
Additional context
Found while adjudicating the results of an authorized black-box test of our own deployment. Happy to supply a server-side stack trace or debug-level logs from a reproduction if that would help isolate where the validation error is being promoted to an internal one.
Source: zitadel/zitadel