PrefectHQ/fastmcp · error · IdentityAssertionError

Assertion must include sub claim

Error message

Assertion must include sub claim

What it means

RFC 7523 §3 makes `sub` (subject) mandatory: it identifies the end user on whose behalf the assertion is presented. FastMCP's identity assertion validator raises this error when the verified JWT claims lack a `sub` or it is empty, since a subject-less assertion cannot yield an identity for the issued access token.

Source

Thrown at fastmcp_slim/fastmcp/server/auth/identity_assertion.py:407

            raise IdentityAssertionError("Assertion must include exp claim")
        if nbf is not None and nbf > now + self.CLOCK_SKEW_SECONDS:
            raise IdentityAssertionError("Assertion is not yet valid (nbf in future)")
        if iat is not None:
            if iat > now + self.CLOCK_SKEW_SECONDS:
                raise IdentityAssertionError("Assertion iat is in the future")
            if exp - iat > self.MAX_ASSERTION_LIFETIME:
                raise IdentityAssertionError(
                    f"Assertion lifetime too long (max {self.MAX_ASSERTION_LIFETIME}s)"
                )
        elif exp > now + self.MAX_ASSERTION_LIFETIME:
            raise IdentityAssertionError(
                f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)"
            )

        # 4. sub is mandatory (RFC 7523 §3) — it identifies the end user.
        sub = claims.get("sub")
        if not sub:
            raise IdentityAssertionError("Assertion must include sub claim")

        # 5. Required scopes on the issued access token derive from the assertion.
        if self.config.required_scopes:
            granted = set(_assertion_scopes(claims))
            missing = set(self.config.required_scopes) - granted
            if missing:
                raise IdentityAssertionError(
                    f"Assertion missing required scopes: {sorted(missing)}"
                )

        # 6. The signed client_id and resource claims bind the assertion to the
        # presenting client and this server. Checked here — before jti is
        # recorded as consumed below — so an assertion presented with the
        # wrong binding is rejected without burning replay protection for
        # whichever client/server it actually belongs to.
        assertion_client_id = claims.get("client_id")
        if not assertion_client_id or assertion_client_id != client_id:
            raise IdentityAssertionError(

View on GitHub (pinned to 1f02114297)

Solutions

  1. Configure the issuer to include the end user's identifier as `sub` in the assertion payload.
  2. If minting assertions in code, add `sub: <user_id>` before signing.
  3. Check IdP claim-mapping/transform rules that may strip or rename sub (e.g. mapped to user_id).
  4. Decode the JWT and confirm `sub` is a non-empty string.

Example fix

// before
claims = {"iss": iss, "aud": aud, "exp": now + 300, "jti": jti}
// after
claims = {"iss": iss, "sub": user_id, "aud": aud, "exp": now + 300, "jti": jti}
Defensive patterns

Strategy: validation

Validate before calling

def has_sub(claims: dict) -> bool:
    sub = claims.get("sub")
    return isinstance(sub, str) and bool(sub)

Type guard

def has_nonempty_str(claims: dict, key: str) -> bool:
    v = claims.get(key)
    return isinstance(v, str) and bool(v)

Try / catch

try:
    token = await exchange(assertion)
except IdentityAssertionError as e:
    if "sub claim" in str(e):
        raise ValueError("Issuer misconfiguration: assertions lack a subject") from e
    raise

Prevention

When it happens

Trigger: Calling `validate()` with an assertion whose payload has no `sub` claim, `sub: null`, or `sub: ""` — typically from an issuer configured for machine-only tokens or a custom minting function that forgot the subject.

Common situations: Service-to-service token templates that omit sub; custom assertion minters copying only iss/aud/exp; IdPs issuing anonymous or pre-auth tokens repurposed as identity assertions.

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/4108c0657bdd7a48. Report an issue: GitHub.