Skip to main content

JWT-AT claim set (RFC 9068)

The shape

REQUIRED claims (per RFC 9068 §2.2): iss=https://as.oid4pay.com, exp, aud (the resource URL the AT is bound to; see resource indicators), sub (principal_id), client_id, iat, jti. RECOMMENDED: auth_time, acr, amr, scope. For DPoP binding (RFC 9449 §6): cnf.jkt matching the agent's DPoP key thumbprint.

REQUIRED header: typ=at+jwt (RFC 9068 §2.1). This prevents ID-token-as-AT confusion attacks.

AT TTL: 300 seconds (5 minutes). Short-lived to limit leak window; refresh tokens cover the UX gap.

Header

{
  "typ": "at+jwt",
  "alg": "EdDSA",
  "kid": "as-2026-05-14"
}

Claim set

{
  "iss": "https://as.oid4pay.com",
  "sub": "principal_id_b64url",
  "aud": "https://shop.alpacanica.com",
  "client_id": "client_abc",
  "jti": "01HJ9XK0YN0K6V6S8Y8E5P5W6Y",
  "exp": 1747260600,
  "iat": 1747260300,
  "nbf": 1747260300,
  "scope": "oid4ac:payment",
  "auth_time": 1747260280,
  "acr": "urn:oid4pay:webauthn:2fa",
  "amr": ["pwd", "webauthn"],
  "cnf": { "jkt": "base64url-sha256-of-dpop-public-jwk" },
  "mandate_id": "mandate_xyz",
  "agent_client_id": "client_abc"
}

Verifier checklist

Every protected endpoint MUST validate:

  1. Header typ=at+jwt exactly.
  2. Signature against the AS JWKS at https://as.oid4pay.com/oauth/jwks.json.
  3. iss=https://as.oid4pay.com exactly.
  4. aud matches the RS's own resource URL exactly.
  5. exp in the future.
  6. cnf.jkt matches the JWK thumbprint of the DPoP proof on the same request.
  7. scope includes a value the RS recognises.

Worked example

// Node merchant SDK verifier (under the hood):
import { importJWK, jwtVerify } from "jose";
import { calculateJwkThumbprint } from "jose";

async function verifyJwtAt(token, dpopJwk, expectedAudience) {
  const jwks = await fetchAsJwks();
  const { payload, protectedHeader } = await jwtVerify(token, jwks, {
    issuer: "https://as.oid4pay.com",
    audience: expectedAudience,
    typ: "at+jwt",
    algorithms: ["EdDSA"],
  });
  const dpopJkt = await calculateJwkThumbprint(dpopJwk, "sha256");
  if (payload.cnf?.jkt !== dpopJkt) {
    throw new Error("dpop_binding_mismatch");
  }
  return payload;
}

Why typ pinning matters

Without typ=at+jwt, a verifier that accepts an ID token by mistake would let an attacker present a long-lived id_token as an access token. Pinning typ in the header rejects the wrong-class token before signature verification.