Skip to content

Verify the JWT (defense in depth)

The load-bearing lesson from Edge auth — overview: platform-injected headers are trustworthy only while the proxy is on the request path. When you can't guarantee that (VPC bypass, misconfigured ingress, direct-to-service URL), you have to verify the JWT yourself in the backend. This page shows the exact per-cloud verify call — signature, audience, expiry, and any provider-specific extras.

Verified 2026-08-08

What "verify" means

Three things always, per provider:

  1. Signature. Recompute against the provider's JWKS.
  2. Audience. Check aud matches your backend/service/app.
  3. Expiry / nbf. Standard JWT rules — the library handles this if you don't pass verify_exp=False.

Plus one provider-specific extra:

  • AWS: the JWT header carries signer, which is your ALB's ARN. Check it against a value you know at deploy time. Without this, a JWT from any ALB in the region validates against the same regional public-key endpoint.
  • GCP: the aud claim's format encodes the resource type + id (backend service / App Engine app / Cloud Run service). Match the exact string.
  • Azure: Easy Auth on App Service strips the client-supplied headers itself; if you're inside the App Service backend, the identity headers are already verified by the platform. If your app is behind Front Door or Application Gateway and also reachable directly, decode X-MS-CLIENT-PRINCIPAL (Base64 JSON) and validate the ID token in the X-MS-TOKEN-AAD-ID-TOKEN header against Entra's OpenID metadata (https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration) using the ID token's aud.

Per-cloud verify code

In-app service, no bypass path — trust the headers Easy Auth injects:

import base64, json
from typing import Mapping

def azure_end_user_oid(headers: Mapping[str, str]) -> str:
    # Fast path: Easy Auth already verified; the immutable oid is here.
    oid = headers.get("x-ms-client-principal-id", "")
    if oid:
        return oid

    # Fallback: decode the client-principal blob and pull the oid claim.
    blob = headers.get("x-ms-client-principal", "")
    if not blob:
        raise PermissionError("no Easy Auth headers — is Easy Auth enabled?")
    data = json.loads(base64.b64decode(blob))
    for c in data.get("claims", []):
        if c.get("typ") in (
            "oid",
            "http://schemas.microsoft.com/identity/claims/objectidentifier",
        ):
            return c["val"]
    raise PermissionError("Easy Auth payload missing oid claim")

If the topology can bypass Easy Auth (Front Door pointing at the App Service origin, and the origin FQDN is still resolvable / not IP-restricted), also verify the ID token from X-MS-TOKEN-AAD-ID-TOKEN against Entra's OIDC metadata. See App Service — user identities + the standard Entra ID-token validation flow.

ALWAYS verify — IAP strips inbound x-goog-* at the LB, but backends reachable via internal VPC do not get that stripping.

import os
from typing import Mapping
from google.auth.transport import requests as gauth_requests
from google.oauth2 import id_token

_IAP_JWKS_URL = "https://www.gstatic.com/iap/verify/public_key"

def gcp_end_user_oid(headers: Mapping[str, str]) -> str:
    jwt = headers.get("x-goog-iap-jwt-assertion", "")
    if not jwt:
        raise PermissionError("no IAP header — IAP not in the request path")

    # Audience formats:
    #   /projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID   (Compute/GKE)
    #   /projects/PROJECT_NUMBER/apps/PROJECT_ID                      (App Engine)
    #   /projects/PROJECT_NUMBER/locations/REGION/services/SERVICE    (Cloud Run)
    audience = os.environ["IAP_JWT_AUDIENCE"]

    claims = id_token.verify_token(
        jwt,
        request=gauth_requests.Request(),
        audience=audience,
        certs_url=_IAP_JWKS_URL,
    )
    # Also enforce that email is present + verified if you rely on it.
    if not claims.get("email_verified", True):
        raise PermissionError("IAP JWT: email not verified")
    return claims["sub"]

Never accept sub from x-goog-authenticated-user-id on its own — it's plaintext and IAP-independent code paths can spoof it. Verify the JWT and take sub from the verified claims.

ALWAYS verify signature and signer ARN — no path from a target back to "was this really from my ALB" without checking both.

import functools, os
from typing import Mapping
from urllib.request import urlopen
from jose import jwt

def _alb_public_key_url(region: str, kid: str) -> str:
    return f"https://public-keys.auth.elb.{region}.amazonaws.com/{kid}"

@functools.lru_cache(maxsize=32)
def _fetch_alb_public_key(region: str, kid: str) -> bytes:
    # PEM, cache per (region, kid). Rotation is rare; kid changes on rotation.
    return urlopen(_alb_public_key_url(region, kid)).read()  # noqa: S310 (trusted AWS host)

def aws_end_user_oid(headers: Mapping[str, str]) -> str:
    signed = headers.get("x-amzn-oidc-data", "")
    if not signed:
        raise PermissionError("no ALB OIDC header — ALB OIDC not in the path")

    # 1. Unverified header — need `kid` + `signer` before we can verify.
    unverified = jwt.get_unverified_header(signed)
    kid    = unverified["kid"]
    signer = unverified["signer"]

    # 2. Signer MUST match the ALB ARN we deployed behind.
    expected_alb_arn = os.environ["EXPECTED_ALB_ARN"]
    if signer != expected_alb_arn:
        raise PermissionError(
            f"ALB OIDC signer mismatch: got {signer!r}, expected {expected_alb_arn!r}",
        )

    # 3. Fetch the PEM (cached) and verify the signature. Audience varies
    #    per IdP so we skip `aud` here — the signer check above binds the
    #    token to a specific ALB, which is the load-bearing property.
    region = os.environ.get("AWS_REGION", "us-east-1")
    pem = _fetch_alb_public_key(region, kid)
    claims = jwt.decode(
        signed, pem, algorithms=["ES256"],
        options={"verify_aud": False},
    )
    return claims["sub"]

The signer check is the load-bearing line. Without it a valid x-amzn-oidc-data from any ALB in the region verifies successfully against the same regional public-key endpoint. awslabs/aws-jwt-verify (Node.js) does this by default; Python callers must do it themselves — the AWS docs are explicit about it: "you must verify the signature before doing any authorization based on the claims and validate that the signer field in the JWT header contains the expected Application Load Balancer ARN."

The one function every recipe imports

The passwordless deep uses actor_oid in the attributed-write pattern; every other Chiron recipe that touches a user does the same lookup. Give it one home:

def end_user_oid(headers: Mapping[str, str]) -> str:
    """Verified end-user immutable id. Raises on missing / invalid claim.

    Dispatches on CHIRON_PROVIDER; each backend does its own verification
    (see the tabs above). No silent fallback — a missing header means the
    request bypassed the edge proxy, and refusing is the correct answer.
    """
    ...

Full implementation in examples/trust/edge-auth/verify.py — the surface intro's examples/trust/service/edge_claim.py is the smaller sketch; this deep version is the one production code imports.

Anti-patterns that keep coming back

  • Trusting the plaintext x-amzn-oidc-identity / x-goog-authenticated-user-id. Both are pre-signature convenience headers. In every topology where the proxy isn't on the path, they're forgeable. Take sub only from the verified JWT payload.
  • Verifying without checking signer on AWS. As above — a valid JWT from any other ALB in the region will pass otherwise.
  • Caching the JWKS response forever. Rotate happens (rarely); cache with a bounded TTL or by kid, not indefinitely.
  • verify_exp=False in production. Only ever pass this in tests that need frozen-time fixtures.

Where the arc goes next

  • Cross-account / cross-tenant federation — how the same edge auth story survives when your workload identity, DB, and end-user IdP live in different account boundaries.
  • RBAC / RLS composed on top of the attributed-writes pattern — the verified oid/sub from this page is the one that lands in the DB actor_oid column.