Skip to content

API Reference

This page documents the public API of the oidc-jwt-verifier library.

Public API

The library exports three main components:

  • AuthConfig - Configuration dataclass for JWT verification settings
  • AuthError - Exception type for authentication/authorization failures
  • JWTVerifier - Main verifier class for validating JWTs

Configuration

AuthConfig(issuer: str, audience: str | Sequence[str], jwks_url: str, allowed_algs: Sequence[str] = ('RS256',), leeway_s: int = 0, jwks_timeout_s: int = 3, jwks_cache_ttl_s: int = 300, jwks_max_cached_keys: int = 16, required_scopes: Sequence[str] = (), required_permissions: Sequence[str] = (), scope_claim: str = 'scope', permissions_claim: str = 'permissions') dataclass

Immutable configuration for JWT verification.

This dataclass holds all settings required by JWTVerifier to validate JWTs against an OIDC provider. The configuration is frozen (immutable) and uses slots for memory efficiency.

All string inputs are stripped of leading/trailing whitespace during validation. Sequences are normalized to tuples.

Attributes:

Name Type Description
issuer str

The expected iss claim value. Must match the token issuer exactly. Typically the OIDC provider URL (e.g., https://example.auth0.com/).

audience str | Sequence[str]

One or more expected aud claim values. The token must contain at least one matching audience. Accepts a single string or a sequence of strings.

jwks_url str

The URL to fetch the JSON Web Key Set from. This URL is used for all key lookups; the verifier never derives JWKS URLs from token headers.

allowed_algs Sequence[str]

Permitted signing algorithms. Defaults to ("RS256",). The none algorithm is always rejected regardless of this setting.

leeway_s int

Clock skew tolerance in seconds for exp and nbf claim validation. Defaults to 0.

jwks_timeout_s int

HTTP timeout in seconds for JWKS fetches. Defaults to 3.

jwks_cache_ttl_s int

Time-to-live in seconds for cached JWKS data. Must be in the range (0, 86400]. Defaults to 300.

jwks_max_cached_keys int

Maximum number of signing keys to cache. Must be in the range (0, 1024]. Defaults to 16.

required_scopes Sequence[str]

Scopes that must be present in the token for authorization to succeed. Checked against the scope_claim. Defaults to an empty tuple (no scope requirements).

required_permissions Sequence[str]

Permissions that must be present in the token. Checked against the permissions_claim. Defaults to an empty tuple.

scope_claim str

The claim name containing OAuth 2.0 scopes. Defaults to "scope".

permissions_claim str

The claim name containing permissions (commonly used by Auth0). Defaults to "permissions".

Raises:

Type Description
ValueError

If any validation constraint is violated during construction. Specific conditions include: - Empty or whitespace-only issuer, jwks_url, or audience. - Empty or whitespace-only allowed_algs, or inclusion of the none algorithm. - Negative leeway_s. - Non-positive jwks_timeout_s. - jwks_cache_ttl_s outside (0, 86400]. - jwks_max_cached_keys outside (0, 1024]. - Empty or whitespace-only scope_claim or permissions_claim.

Examples:

Minimal configuration for Auth0:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
... )
>>> config.audiences
('https://api.example.com',)
>>> config.allowed_algorithms
('RS256',)

Configuration with multiple audiences and scope requirements:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience=["https://api.example.com", "https://api2.example.com"],
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
...     allowed_algs=["RS256", "RS384"],
...     required_scopes=["read:users", "write:users"],
... )
>>> config.audiences
('https://api.example.com', 'https://api2.example.com')
>>> config.required_scope_set
{'read:users', 'write:users'}

Invalid configuration raises ValueError:

>>> AuthConfig(
...     issuer="",
...     audience="api",
...     jwks_url="https://example.com/.well-known/jwks.json",
... )
Traceback (most recent call last):
    ...
ValueError: issuer must be non-empty

audiences: tuple[str, ...] property

Return the configured audiences as a tuple.

This property provides consistent tuple access regardless of whether the audience attribute was initialized with a single string or a sequence.

Returns:

Type Description
tuple[str, ...]

A tuple of audience strings.

Examples:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
... )
>>> config.audiences
('https://api.example.com',)

allowed_algorithms: tuple[str, ...] property

Return the allowed algorithms as a tuple.

This property provides consistent tuple access regardless of whether the allowed_algs attribute was initialized with a single string or a sequence.

Returns:

Type Description
tuple[str, ...]

A tuple of algorithm name strings.

Examples:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
...     allowed_algs=["RS256", "ES256"],
... )
>>> config.allowed_algorithms
('RS256', 'ES256')

required_scope_set: set[str] property

Return the required scopes as a set for efficient membership testing.

Empty strings in the required_scopes sequence are filtered out.

Returns:

Type Description
set[str]

A set of non-empty scope strings.

Examples:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
...     required_scopes=["read:users", "write:users"],
... )
>>> config.required_scope_set == {"read:users", "write:users"}
True

required_permission_set: set[str] property

Return the required permissions as a set for efficient membership testing.

Empty strings in the required_permissions sequence are filtered out.

Returns:

Type Description
set[str]

A set of non-empty permission strings.

Examples:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
...     required_permissions=["admin", "editor"],
... )
>>> config.required_permission_set == {"admin", "editor"}
True

__post_init__() -> None

Validate and normalize configuration values after initialization.

This method runs automatically after dataclass initialization. It strips whitespace from string values, normalizes sequences to tuples, and validates all constraints.

Raises:

Type Description
ValueError

If any validation constraint is violated.

Source code in oidc_jwt_verifier/config.py
def __post_init__(self) -> None:
    """Validate and normalize configuration values after initialization.

    This method runs automatically after dataclass initialization. It
    strips whitespace from string values, normalizes sequences to tuples,
    and validates all constraints.

    Raises:
        ValueError: If any validation constraint is violated.
    """
    issuer = self.issuer.strip()
    if not issuer:
        raise ValueError("issuer must be non-empty")
    object.__setattr__(self, "issuer", issuer)

    jwks_url = self.jwks_url.strip()
    if not jwks_url:
        raise ValueError("jwks_url must be non-empty")
    object.__setattr__(self, "jwks_url", jwks_url)

    audiences = tuple(a.strip() for a in _normalize_str_sequence(self.audience))
    if not audiences or any(not a for a in audiences):
        raise ValueError("audience must be non-empty")
    object.__setattr__(self, "audience", audiences)

    allowed_algs = tuple(a.strip() for a in _normalize_str_sequence(self.allowed_algs))
    if not allowed_algs or any(not a for a in allowed_algs):
        raise ValueError("allowed_algs must be non-empty")
    if any(a.lower() == "none" for a in allowed_algs):
        raise ValueError("allowed_algs must not include 'none'")
    object.__setattr__(self, "allowed_algs", allowed_algs)

    if self.leeway_s < 0:
        raise ValueError("leeway_s must be >= 0")

    if self.jwks_timeout_s <= 0:
        raise ValueError("jwks_timeout_s must be > 0")
    if not 0 < self.jwks_cache_ttl_s <= 24 * 60 * 60:
        raise ValueError("jwks_cache_ttl_s must be in (0, 86400]")
    if not 0 < self.jwks_max_cached_keys <= 1024:
        raise ValueError("jwks_max_cached_keys must be in (0, 1024]")

    object.__setattr__(
        self,
        "required_scopes",
        tuple(s.strip() for s in _normalize_str_sequence(self.required_scopes)),
    )
    object.__setattr__(
        self,
        "required_permissions",
        tuple(p.strip() for p in _normalize_str_sequence(self.required_permissions)),
    )

    scope_claim = self.scope_claim.strip()
    if not scope_claim:
        raise ValueError("scope_claim must be non-empty")
    object.__setattr__(self, "scope_claim", scope_claim)

    permissions_claim = self.permissions_claim.strip()
    if not permissions_claim:
        raise ValueError("permissions_claim must be non-empty")
    object.__setattr__(self, "permissions_claim", permissions_claim)

Errors

AuthError(*, code: str, message: str, status_code: int, required_scopes: Iterable[str] = (), required_permissions: Iterable[str] = ())

Bases: Exception

Exception raised on authentication or authorization failure.

This exception provides structured error information including a stable error code for programmatic handling, an HTTP status code (401 for authentication failures, 403 for authorization failures), and a method to generate RFC 6750-compliant WWW-Authenticate header values.

The exception message is accessible via the standard str() conversion or the message attribute.

Attributes:

Name Type Description
code

A stable string identifier for the error type. Common values include "invalid_token", "token_expired", "insufficient_scope", and "missing_token". Suitable for programmatic error handling and logging.

message

A human-readable description of the error. This is also set as the exception message.

status_code

The HTTP status code to return. Must be 401 (Unauthorized) for authentication errors or 403 (Forbidden) for authorization errors.

required_scopes

A tuple of scope strings that were required but missing from the token. Populated for insufficient_scope errors; empty for other error types.

required_permissions

A tuple of permission strings that were required but missing from the token. Populated for insufficient_permissions errors; empty for other error types.

Raises:

Type Description
ValueError

If status_code is not 401 or 403.

Examples:

Creating an authentication error (401):

>>> error = AuthError(
...     code="token_expired",
...     message="Token is expired",
...     status_code=401,
... )
>>> str(error)
'Token is expired'
>>> error.code
'token_expired'
>>> error.status_code
401

Creating an authorization error (403) with scope requirements:

>>> error = AuthError(
...     code="insufficient_scope",
...     message="Insufficient scope",
...     status_code=403,
...     required_scopes=["read:users", "write:users"],
... )
>>> error.required_scopes
('read:users', 'write:users')

Generating a WWW-Authenticate header:

>>> error = AuthError(
...     code="invalid_token",
...     message="Malformed token",
...     status_code=401,
... )
>>> error.www_authenticate_header(realm="api")
'Bearer realm="api", error="invalid_token", error_description="Malformed token"'

Invalid status code raises ValueError:

>>> AuthError(code="error", message="msg", status_code=500)
Traceback (most recent call last):
    ...
ValueError: status_code must be 401 or 403

Initialize an authentication or authorization error.

Parameters:

Name Type Description Default
code str

A stable string identifier for the error type.

required
message str

A human-readable error description.

required
status_code int

The HTTP status code (must be 401 or 403).

required
required_scopes Iterable[str]

Scopes that were required but missing. Defaults to an empty tuple.

()
required_permissions Iterable[str]

Permissions that were required but missing. Defaults to an empty tuple.

()

Raises:

Type Description
ValueError

If status_code is not 401 or 403.

Source code in oidc_jwt_verifier/errors.py
def __init__(
    self,
    *,
    code: str,
    message: str,
    status_code: int,
    required_scopes: Iterable[str] = (),
    required_permissions: Iterable[str] = (),
) -> None:
    """Initialize an authentication or authorization error.

    Args:
        code: A stable string identifier for the error type.
        message: A human-readable error description.
        status_code: The HTTP status code (must be 401 or 403).
        required_scopes: Scopes that were required but missing.
            Defaults to an empty tuple.
        required_permissions: Permissions that were required but missing.
            Defaults to an empty tuple.

    Raises:
        ValueError: If ``status_code`` is not 401 or 403.
    """
    if status_code not in (401, 403):
        raise ValueError("status_code must be 401 or 403")
    super().__init__(message)
    self.code = code
    self.message = message
    self.status_code = status_code
    self.required_scopes = tuple(required_scopes)
    self.required_permissions = tuple(required_permissions)

www_authenticate_header(*, realm: str | None = None) -> str

Generate an RFC 6750-compliant WWW-Authenticate header value.

Constructs a Bearer authentication challenge suitable for use as the value of an HTTP WWW-Authenticate header. The challenge includes the error type (mapped to RFC 6750 error codes) and a description.

RFC 6750 defines two relevant error codes: - invalid_token: Used for 401 errors (authentication failures). - insufficient_scope: Used for 403 errors (authorization failures).

If required_scopes is non-empty, a scope parameter is included listing the missing scopes.

Parameters:

Name Type Description Default
realm str | None

Optional protection space identifier. If provided, it appears first in the challenge parameters. Common values include the API name or domain.

None

Returns:

Type Description
str

A string suitable for use as the WWW-Authenticate header value.

str

The format is Bearer param1="value1", param2="value2", ....

Examples:

Basic authentication error:

>>> error = AuthError(
...     code="invalid_token",
...     message="Token is expired",
...     status_code=401,
... )
>>> error.www_authenticate_header()
'Bearer error="invalid_token", error_description="Token is expired"'

With realm:

>>> error.www_authenticate_header(realm="my-api")
'Bearer realm="my-api", error="invalid_token", error_description="Token is expired"'

Authorization error with required scopes:

>>> error = AuthError(
...     code="insufficient_scope",
...     message="Insufficient scope",
...     status_code=403,
...     required_scopes=["read:users"],
... )
>>> header = error.www_authenticate_header()
>>> "insufficient_scope" in header
True
>>> "read:users" in header
True
Source code in oidc_jwt_verifier/errors.py
def www_authenticate_header(self, *, realm: str | None = None) -> str:
    """Generate an RFC 6750-compliant WWW-Authenticate header value.

    Constructs a Bearer authentication challenge suitable for use as the
    value of an HTTP WWW-Authenticate header. The challenge includes
    the error type (mapped to RFC 6750 error codes) and a description.

    RFC 6750 defines two relevant error codes:
    - ``invalid_token``: Used for 401 errors (authentication failures).
    - ``insufficient_scope``: Used for 403 errors (authorization failures).

    If ``required_scopes`` is non-empty, a ``scope`` parameter is
    included listing the missing scopes.

    Args:
        realm: Optional protection space identifier. If provided, it
            appears first in the challenge parameters. Common values
            include the API name or domain.

    Returns:
        A string suitable for use as the WWW-Authenticate header value.
        The format is ``Bearer param1="value1", param2="value2", ...``.

    Examples:
        Basic authentication error:

        >>> error = AuthError(
        ...     code="invalid_token",
        ...     message="Token is expired",
        ...     status_code=401,
        ... )
        >>> error.www_authenticate_header()
        'Bearer error="invalid_token", error_description="Token is expired"'

        With realm:

        >>> error.www_authenticate_header(realm="my-api")
        'Bearer realm="my-api", error="invalid_token", error_description="Token is expired"'

        Authorization error with required scopes:

        >>> error = AuthError(
        ...     code="insufficient_scope",
        ...     message="Insufficient scope",
        ...     status_code=403,
        ...     required_scopes=["read:users"],
        ... )
        >>> header = error.www_authenticate_header()
        >>> "insufficient_scope" in header
        True
        >>> "read:users" in header
        True
    """
    params: list[str] = []
    if realm is not None:
        params.append(f"realm={_quote_rfc6750_value(realm)}")

    if self.status_code == 403:
        rfc6750_error = "insufficient_scope"
    else:
        rfc6750_error = "invalid_token"

    params.append(f"error={_quote_rfc6750_value(rfc6750_error)}")
    params.append(f"error_description={_quote_rfc6750_value(self.message)}")

    if self.required_scopes:
        scope_str = " ".join(self.required_scopes)
        params.append(f"scope={_quote_rfc6750_value(scope_str)}")

    if self.required_permissions:
        permissions_str = " ".join(self.required_permissions)
        params.append(f"permissions={_quote_rfc6750_value(permissions_str)}")

    return "Bearer " + ", ".join(params)

Verifier

JWTVerifier(config: AuthConfig)

Stateful JWT verifier for OIDC access tokens.

This class performs complete JWT verification including:

  1. Header validation: Rejects tokens with dangerous headers (jku, x5u, crit) and ensures the algorithm is in the allowlist.
  2. Key retrieval: Fetches the signing key from the JWKS using the token's kid header.
  3. Signature verification: Validates the cryptographic signature.
  4. Claim validation: Checks iss, aud, exp, and nbf claims against configuration.
  5. Authorization enforcement: Verifies required scopes and permissions are present (returns 403 on failure).

The verifier maintains a cached JWKS client for efficient key lookups across multiple token verifications.

Attributes:

Name Type Description
_config

The authentication configuration.

_jwks

The JWKS client for signing key retrieval.

Examples:

Basic token verification:

>>> from oidc_jwt_verifier import AuthConfig, AuthError, JWTVerifier
>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
... )
>>> verifier = JWTVerifier(config)
>>> claims = verifier.verify_access_token(token)
>>> claims["sub"]
'auth0|123456789'

Handling verification errors:

>>> try:
...     claims = verifier.verify_access_token(expired_token)
... except AuthError as e:
...     print(f"Error: {e.code}, Status: {e.status_code}")
...     print(e.www_authenticate_header())
Error: token_expired, Status: 401
Bearer error="invalid_token", error_description="Token is expired"

Verifying tokens with scope requirements:

>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
...     required_scopes=["read:users"],
... )
>>> verifier = JWTVerifier(config)
>>> # Token without required scopes raises AuthError with 403
>>> claims = verifier.verify_access_token(token_without_scopes)
Traceback (most recent call last):
    ...
AuthError: Insufficient scope

Initialize a JWT verifier with the given configuration.

Creates a JWKS client configured with the caching parameters from the provided configuration.

Parameters:

Name Type Description Default
config AuthConfig

The authentication configuration specifying the issuer, audience, JWKS URL, allowed algorithms, and authorization requirements.

required

Examples:

>>> from oidc_jwt_verifier import AuthConfig
>>> config = AuthConfig(
...     issuer="https://example.auth0.com/",
...     audience="https://api.example.com",
...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
... )
>>> verifier = JWTVerifier(config)
Source code in oidc_jwt_verifier/verifier.py
def __init__(self, config: AuthConfig) -> None:
    """Initialize a JWT verifier with the given configuration.

    Creates a JWKS client configured with the caching parameters
    from the provided configuration.

    Args:
        config: The authentication configuration specifying the
            issuer, audience, JWKS URL, allowed algorithms, and
            authorization requirements.

    Examples:
        >>> from oidc_jwt_verifier import AuthConfig
        >>> config = AuthConfig(
        ...     issuer="https://example.auth0.com/",
        ...     audience="https://api.example.com",
        ...     jwks_url="https://example.auth0.com/.well-known/jwks.json",
        ... )
        >>> verifier = JWTVerifier(config)  # doctest: +SKIP
    """
    self._config = config
    self._jwks = JWKSClient.from_config(config)

verify_access_token(token: str) -> dict[str, Any]

Verify an access token and return its claims.

Performs the complete verification chain:

  1. Validates the token is non-empty.
  2. Parses and validates the token header (rejects jku, x5u, crit; validates alg and kid).
  3. Fetches the signing key from the JWKS.
  4. Decodes and verifies the token signature.
  5. Validates standard claims (iss, aud, exp, nbf).
  6. Enforces required scopes and permissions.

The method supports Auth0-style multi-audience tokens where the aud claim is an array. Verification succeeds if any configured audience matches any audience in the token.

Parameters:

Name Type Description Default
token str

The encoded JWT access token string. Leading and trailing whitespace is stripped.

required

Returns:

Type Description
dict[str, Any]

The decoded token payload as a dictionary. Contains all

dict[str, Any]

claims from the token including registered claims (iss,

dict[str, Any]

sub, aud, exp, etc.) and any custom claims.

Raises:

Type Description
AuthError

On any verification failure. The error's status_code indicates the appropriate HTTP response: - 401 for authentication failures (missing token, malformed token, invalid signature, expired token, wrong issuer/audience). - 403 for authorization failures (insufficient scopes or permissions).

Specific error codes include: - "missing_token": Empty or whitespace-only token. - "malformed_token": Unparseable token or missing alg header. - "forbidden_header": Token contains jku, x5u, or crit headers. - "disallowed_alg": Algorithm not in allowlist or is none. - "missing_kid": Token lacks kid header. - "token_expired": Token exp is in the past. - "token_not_yet_valid": Token nbf is in the future. - "invalid_issuer": iss claim mismatch. - "invalid_audience": aud claim mismatch. - "insufficient_scope": Missing required scopes. - "insufficient_permissions": Missing required permissions.

Examples:

Successful verification:

>>> claims = verifier.verify_access_token(valid_token)
>>> claims["sub"]
'auth0|123456789'
>>> claims["aud"]
'https://api.example.com'

Missing token:

>>> verifier.verify_access_token("")
Traceback (most recent call last):
    ...
AuthError: Missing access token

Expired token:

>>> verifier.verify_access_token(expired_token)
Traceback (most recent call last):
    ...
AuthError: Token is expired
Source code in oidc_jwt_verifier/verifier.py
def verify_access_token(self, token: str) -> dict[str, Any]:
    """Verify an access token and return its claims.

    Performs the complete verification chain:

    1. Validates the token is non-empty.
    2. Parses and validates the token header (rejects ``jku``, ``x5u``,
       ``crit``; validates ``alg`` and ``kid``).
    3. Fetches the signing key from the JWKS.
    4. Decodes and verifies the token signature.
    5. Validates standard claims (``iss``, ``aud``, ``exp``, ``nbf``).
    6. Enforces required scopes and permissions.

    The method supports Auth0-style multi-audience tokens where the
    ``aud`` claim is an array. Verification succeeds if any configured
    audience matches any audience in the token.

    Args:
        token: The encoded JWT access token string. Leading and
            trailing whitespace is stripped.

    Returns:
        The decoded token payload as a dictionary. Contains all
        claims from the token including registered claims (``iss``,
        ``sub``, ``aud``, ``exp``, etc.) and any custom claims.

    Raises:
        AuthError: On any verification failure. The error's
            ``status_code`` indicates the appropriate HTTP response:
            - 401 for authentication failures (missing token,
              malformed token, invalid signature, expired token,
              wrong issuer/audience).
            - 403 for authorization failures (insufficient scopes
              or permissions).

            Specific error codes include:
            - ``"missing_token"``: Empty or whitespace-only token.
            - ``"malformed_token"``: Unparseable token or missing
              ``alg`` header.
            - ``"forbidden_header"``: Token contains ``jku``,
              ``x5u``, or ``crit`` headers.
            - ``"disallowed_alg"``: Algorithm not in allowlist or
              is ``none``.
            - ``"missing_kid"``: Token lacks ``kid`` header.
            - ``"token_expired"``: Token ``exp`` is in the past.
            - ``"token_not_yet_valid"``: Token ``nbf`` is in the
              future.
            - ``"invalid_issuer"``: ``iss`` claim mismatch.
            - ``"invalid_audience"``: ``aud`` claim mismatch.
            - ``"insufficient_scope"``: Missing required scopes.
            - ``"insufficient_permissions"``: Missing required
              permissions.

    Examples:
        Successful verification:

        >>> claims = verifier.verify_access_token(valid_token)  # doctest: +SKIP
        >>> claims["sub"]  # doctest: +SKIP
        'auth0|123456789'
        >>> claims["aud"]  # doctest: +SKIP
        'https://api.example.com'

        Missing token:

        >>> verifier.verify_access_token("")  # doctest: +SKIP
        Traceback (most recent call last):
            ...
        AuthError: Missing access token

        Expired token:

        >>> verifier.verify_access_token(expired_token)  # doctest: +SKIP
        Traceback (most recent call last):
            ...
        AuthError: Token is expired
    """
    token = token.strip()
    if not token:
        raise AuthError(code="missing_token", message="Missing access token", status_code=401)

    # Parse the header without verifying the signature.
    try:
        header = jwt.get_unverified_header(token)
    except jwt.exceptions.DecodeError as exc:
        raise AuthError(
            code="malformed_token",
            message="Malformed token",
            status_code=401,
        ) from exc

    # Reject dangerous header parameters that could be used for attacks.
    # - jku: URL to fetch keys from (could point to attacker-controlled server)
    # - x5u: URL to fetch X.509 certificate (same risk as jku)
    # - crit: Critical headers that must be understood (complexity attack vector)
    if "jku" in header or "x5u" in header or "crit" in header:
        raise AuthError(
            code="forbidden_header",
            message="Forbidden token header parameter",
            status_code=401,
        )

    # Validate the algorithm header.
    alg = header.get("alg")
    if not isinstance(alg, str) or not alg:
        raise AuthError(code="malformed_token", message="Missing alg header", status_code=401)
    if alg.lower() == "none":
        raise AuthError(
            code="disallowed_alg",
            message="Disallowed signing algorithm",
            status_code=401,
        )
    if alg not in self._config.allowed_algorithms:
        raise AuthError(
            code="disallowed_alg",
            message="Disallowed signing algorithm",
            status_code=401,
        )

    # Require kid header for JWKS key lookup.
    kid = header.get("kid")
    if not isinstance(kid, str) or not kid:
        raise AuthError(code="missing_kid", message="Missing kid header", status_code=401)

    # Fetch the signing key from the JWKS.
    signing_key = self._jwks.get_signing_key_from_jwt(token)

    # Configure PyJWT verification options.
    options = {
        "require": ["exp", "iss", "aud"],
        "verify_signature": True,
        "verify_exp": True,
        "verify_nbf": True,
        "verify_aud": True,
        "verify_iss": True,
        "strict_aud": False,  # Allow aud to be array (Auth0 style)
    }

    # Try each configured audience until one matches.
    # This handles tokens with array aud claims.
    payload: dict[str, Any] | None = None
    last_exc: Exception | None = None
    for audience in self._config.audiences:
        try:
            payload = jwt.decode(
                token,
                signing_key.key,
                algorithms=[alg],
                audience=audience,
                issuer=self._config.issuer,
                leeway=self._config.leeway_s,
                options=options,
            )
            break
        except jwt.InvalidAudienceError as exc:
            last_exc = exc
            continue
        except jwt.PyJWTError as exc:
            raise _map_decode_error(exc) from exc

    if payload is None:
        raise _map_decode_error(last_exc or jwt.InvalidAudienceError("invalid audience"))

    # Enforce required scopes (403 on failure).
    required_scopes = self._config.required_scope_set
    required_permissions = self._config.required_permission_set

    token_scopes = _parse_scope_claim(payload.get(self._config.scope_claim))
    token_permissions = _parse_permissions_claim(payload.get(self._config.permissions_claim))

    missing_scopes = required_scopes - token_scopes
    if missing_scopes:
        raise AuthError(
            code="insufficient_scope",
            message="Insufficient scope",
            status_code=403,
            required_scopes=tuple(sorted(missing_scopes)),
        )

    # Enforce required permissions (403 on failure).
    missing_permissions = required_permissions - token_permissions
    if missing_permissions:
        raise AuthError(
            code="insufficient_permissions",
            message="Insufficient permissions",
            status_code=403,
            required_permissions=tuple(sorted(missing_permissions)),
        )

    return payload