PrefectHQ/fastmcp · error · ValueError
Assertion sub claim must be {client_id}
Error message
Assertion sub claim must be {client_id} What it means
Raised by validate_assertion because the JWT's 'sub' (subject) claim does not equal the client_id being authenticated. RFC 7523 mandates sub == client_id for private_key_jwt so the assertion unambiguously identifies the client; a mismatch means the assertion may be for a different client.
Source
Thrown at fastmcp_slim/fastmcp/server/auth/cimd.py:618
# If iat is present, validate it and check assertion lifetime
if iat:
if iat > now + 30: # 30 second clock skew tolerance
raise ValueError("Assertion iat is in the future")
if exp - iat > self.MAX_ASSERTION_LIFETIME:
raise ValueError(
f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)"
)
else:
# No iat, enforce max lifetime from now
if exp > now + self.MAX_ASSERTION_LIFETIME:
raise ValueError(
f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)"
)
# 4. Additional RFC 7523 validation: sub claim must equal client_id
if claims.get("sub") != client_id:
raise ValueError(f"Assertion sub claim must be {client_id}")
# 5. Check jti for replay attacks (RFC 7523 requirement)
jti = claims.get("jti")
if not jti:
raise ValueError("Assertion must include jti claim")
# Check if JTI was already used (and hasn't expired from cache)
if jti in self._jti_cache:
cached_exp = self._jti_cache[jti]
if cached_exp > now: # Still valid in cache
raise ValueError(f"Assertion replay detected: jti {jti} already used")
# Expired in cache, can be reused (clean it up)
del self._jti_cache[jti]
# Emergency size limit (shouldn't hit with proper TTL cleanup)
if len(self._jti_cache) >= self._jti_cache_max_size:
self._cleanup_expired_jtis()
# If still over limit after cleanup, reject to prevent DoSView on GitHub (pinned to 1f02114297)
Solutions
- Set the JWT 'sub' claim exactly equal to the client_id passed to the validator
- Fix the assertion-minting code to parameterize sub from the actual client_id
- Verify you are validating against the correct client_id (not an issuer/subject URL)
Example fix
// before payload["sub"] = issuer_url // after payload["sub"] = client_id
Defensive patterns
Strategy: validation
Validate before calling
claims = jwt.decode(token, options={"verify_signature": False})
if claims.get("sub") != client_id:
raise ValueError(f"assertion sub ({claims.get('sub')}) must equal client_id ({client_id})") Type guard
def sub_matches_client(claims: dict, client_id: str) -> bool:
return claims.get("sub") == client_id Try / catch
try:
validator.validate_assertion(token, client_id, jwks)
except ValueError as e:
if "sub claim must be" in str(e):
token = mint_assertion(client_id) # parameterize sub from client_id
else:
raise Prevention
- Always set sub = client_id in private_key_jwt assertions
- Parameterize sub in minting code; never hardcode it
- Add a pre-flight assertion self-check in tests
When it happens
Trigger: Passing client_id X to validate_private_key_jwt while the token was signed with sub = issuer URL or a different client's ID; using the authorization-server issuer URL as sub where this implementation expects the client_id; copy-pasted token minting code with a stale/substituted client_id.
Common situations: Multi-tenant setups where the assertion template hardcodes one client's ID; CIMD clients setting sub to their metadata URL instead of the client_id; rotated client IDs after re-registration.
Related errors
- CIMD document must have jwks_uri or jwks for private_key_jwt
- Assertion must include exp claim
- Assertion iat is in the future
- Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASS
- Assertion exp too far in future (max {self.MAX_ASSERTION_LIF
AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29).
Data as JSON: /api/errors/4584b0e3ab1f7f6b.
Report an issue: GitHub.