#12692·zitadel

jwt-bearer grant returns 500 server_error (Errors.Internal) for any malformed assertion instead of 400 invalid_grant

Author: ZerkerEODCreated Sep 3, 2026Updated Sep 17, 2026

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:

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

  1. 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.
  2. Clients cannot make the right retry decision. server_error is the class a client is supposed to retry and back off against; invalid_grant is 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.
  3. 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.