AuthN vs AuthZ, OAuth 2.1, OIDC, JWTs, mTLS

Separate authentication from authorization, validate a JWT correctly, and choose between bearer, DPoP, and mTLS.

🧱 Module 1 8 min read Not started

Why this matters

You cannot reason about agent identity without rock-solid foundations. Most agent-IAM bugs in production aren't novel — they're the classic OAuth + JWT pitfalls re-skinned. Nail these now; everything later builds on them.

Learning objectives

  1. Cleanly separate authentication, authorization, and accounting.
  2. Know what OAuth 2.1 changes versus 2.0.
  3. Validate a JWT correctly (signature, iss, aud, exp, nbf, jti).
  4. Use OIDC to authenticate users; use OAuth to authorize agents.
  5. Understand when to use bearer tokens vs mTLS vs DPoP.

1. AuthN, AuthZ, AAA

  • Authentication (AuthN)who are you. Proven by credentials (password, passkey, certificate, OIDC ID token).
  • Authorization (AuthZ)what can you do. Decided per request, per resource, by policies, scopes, claims.
  • Accounting / Auditwhat did you do. Append-only logs of decisions and actions.

A surprising number of bugs come from conflating AuthN with AuthZ. "I'm logged in as Alice" does not mean "I'm allowed to refund this customer". Verify both at every trust boundary.

For agents you'll see four principal types: end-user, agent acting for that user, agent acting on its own behalf, and tool / downstream service. Each needs its own AuthN and its own AuthZ policy. (Identity-model details in lesson 1.2.)


2. OAuth 2.1 in one page

OAuth 2.1 is the IETF consolidation of OAuth 2.0 + Best Current Practices + PKCE for all clients. It does not introduce major new flows; it removes the bad ones.

Key changes from 2.0 → 2.1:

  • Implicit flow — removed. (Tokens-in-URL fragment was a perennial XSS leak.)
  • Resource Owner Password Credentials — removed. (Apps must never see passwords.)
  • PKCE mandatory for all clients using the authorization-code flow (public and confidential).
  • Redirect URI matching must be exact string match (no wildcards or path-prefix tricks).
  • Refresh tokens for public clients must be sender-constrained (DPoP) or rotated on every use.
  • Bearer tokens in URI query — forbidden.

The supported flows today:

FlowUse
Authorization Code + PKCEWeb, native, SPA, mobile, anything with a user
Client CredentialsBackend-to-backend, no user
Device Authorization Grant (RFC 8628)CLIs, TVs, devices without browsers
Token Exchange (RFC 8693)Delegation, impersonation, on-behalf-of
CIBA (Client-Initiated Backchannel Authentication)Agents that need user approval out-of-band

All five matter for agents. The classical "user → web app → API" picture is one of many.


3. OpenID Connect (OIDC) in one page

OIDC is a thin identity layer on top of OAuth 2.0/2.1. The big additions:

  • ID token — a JWT proving who logged in (claims: sub, email, name, auth_time, acr, amr).
  • /userinfo endpoint for follow-up profile data.
  • Discovery.well-known/openid-configuration exposes endpoints + signing keys (jwks_uri).
  • nonce in the auth request, echoed in the ID token to bind it to a specific browser session.

Rule of thumb:

  • OIDC = "the user is X" (authentication).
  • OAuth access token = "the agent may do Y" (authorization).

Mixing them is the most common identity bug. The ID token is not for sending to APIs. The access token is not for identifying the user.


4. JWT validation — the part everyone gets wrong

A JWT (RFC 7519) is header.payload.signature, all base64url-encoded. For an access token in JWT form (most IdPs use JWT, though OAuth itself allows opaque tokens):

python
import jwt, requests
from jwt import PyJWKClient

ISSUER = "https://localhost:8443/realms/agents-dev"
AUDIENCE = "agent-api"      # the resource you are
JWKS_URI = requests.get(f"{ISSUER}/.well-known/openid-configuration", verify=False).json()["jwks_uri"]
jwks = PyJWKClient(JWKS_URI)

def verify(token: str) -> dict:
    signing_key = jwks.get_signing_key_from_jwt(token).key
    return jwt.decode(
        token,
        signing_key,
        algorithms=["RS256", "ES256"],
        audience=AUDIENCE,
        issuer=ISSUER,
        options={"require": ["exp", "iat", "sub", "iss", "aud"]},
    )

Things you must check:

  1. Signature (don't accept alg: none; pin algorithms).
  2. iss matches your trusted IdP exactly.
  3. aud includes your resource identifier.
  4. exp is in the future; nbf is in the past.
  5. jti if you implement replay protection (cache for token lifetime).
  6. Algorithm allowlist — never trust the header's alg; reject HS* if you expect RS*/ES*.

Pitfalls:

  • Verifying with the wrong key because you trusted kid blindly → fetch from the IdP's JWKS only.
  • Skipping audience check → token meant for another API accepted.
  • Caching JWKS forever → fails when IdP rotates keys (cache 1 h with revalidation).
  • Using jwt.decode(..., verify=False) for "debugging" then forgetting → silent disaster.

5. Token formats: JWT, opaque, sender-constrained

JWT bearer

Self-contained, statelessly verifiable. Trade-off: revocation is hard (you wait for exp unless you maintain a revocation list).

Opaque

Random string; resource server calls IdP's /introspect endpoint (RFC 7662). Easy revocation; extra hop.

For agents: prefer short-lived JWTs (5-15 min) + refresh tokens + introspect on sensitive operations.

Sender-constrained tokens

A token tied to a key the client holds. Stealing the token alone isn't enough.

  • mTLS-bound tokens (RFC 8705) — client TLS cert thumbprint embedded in token.
  • DPoP (RFC 9449) — client signs each request with a private key; token carries the public-key thumbprint.

For agents calling sensitive APIs or holding refresh tokens for hours, DPoP is the modern answer. Increasingly required by financial APIs (FAPI 2.0).


6. mTLS for service-to-service

When two services you operate talk to each other, mutual TLS often beats bearer tokens:

  • Each party presents a certificate.
  • Identity = the SAN (Subject Alternative Name) on the cert.
  • No bearer tokens to leak; no JWT validation pitfalls.
  • Pairs naturally with SPIFFE/SPIRE (lesson 2.2) for short-lived workload certs.

You'll still use OAuth at the user-facing edge — but inside your VPC, mTLS is simpler and more secure for service-to-service. Use both layers; don't pick one or the other.


7. Scopes vs claims vs permissions

  • Scope — a coarse string asked for at authorization time (read:tickets, write:invoice). User consents to scopes; tokens carry them.
  • Claim — any key/value in the token (sub, email, roles, tenant_id, org).
  • Permission — a fine-grained authorization decision the resource server makes, often using OPA/Cedar/OpenFGA with the token claims as input.

Scopes are blunt. Real authorization is policy + claims + resource context. (Lesson 6.1.)

Anti-pattern: dozens of scopes that mirror your DB schema (read:user.profile.email). Keep scopes few and coarse; do fine-grained checks at the resource server.


8. Token introspection + revocation

  • POST /introspect (RFC 7662): "is this token still valid? what claims does it have?".
  • POST /revoke (RFC 7009): "kill this refresh token / access token".

For agents, introspect on:

  • High-value actions (payments, deletes).
  • The first call of a session.
  • Any time a token has been idle > 5 min.

Don't introspect every request — round-trips kill latency. Cache the result for the token's remaining life or a short window, whichever is shorter.


9. PKCE in pictures

1. Agent generates verifier (random 64 bytes), challenge = SHA-256(verifier) base64url
2. Agent → IdP:   /authorize?...&code_challenge=...&code_challenge_method=S256
3. User authenticates; IdP redirects with ?code=...
4. Agent → IdP:   POST /token  { code, code_verifier, ... }
5. IdP verifies SHA-256(verifier) == stored challenge
6. IdP returns access_token + id_token + refresh_token

PKCE prevents a network attacker who steals the code from exchanging it — they don't have the verifier. Mandatory in OAuth 2.1 even for confidential clients (defence in depth).


10. Hands-on lab (2 h)

  1. Build a tiny FastAPI resource server /me that:
    • Validates JWT via Keycloak JWKS (RS256).
    • Requires aud=agent-api and iss matches your realm.
    • Returns sub, email, roles.
  2. Call it three ways, confirming behaviour:
    • Valid token → 200.
    • Tampered signature → 401.
    • Wrong aud → 401.
    • Expired token → 401.
  3. Add DPoP: generate an EC key in your client, sign each request with a DPoP header containing htu, htm, iat, jti. Verify on the server.
  4. Add mTLS option: turn on Uvicorn --ssl-cert-reqs=2, issue a client cert with step certificate create, call again.

11. Common pitfalls

  1. Treating ID token as access token (or vice versa).
  2. Not pinning algorithms — alg: none and HS/RS confusion attacks.
  3. Wildcard / prefix-match redirect URIs.
  4. Long-lived bearer tokens with no DPoP — equivalent to a password you mailed to a contractor.
  5. Trusting sub across IdPs (each IdP defines sub separately; namespace if you federate).
  6. Skipping nonce in OIDC → ID token replay.

12. Self-check

  1. AuthN vs AuthZ in one sentence each.
  2. Three things OAuth 2.1 removes from OAuth 2.0.
  3. Five JWT checks that must always pass.
  4. Bearer vs DPoP vs mTLS — pick per scenario.
  5. Difference between scope and claim.

13. References

  • OAuth 2.1 draft (IETF draft-ietf-oauth-v2-1).
  • RFC 7519 (JWT), 7515 (JWS), 7517 (JWK), 7518 (JWA).
  • RFC 8252 (Native apps), 8628 (Device flow), 8693 (Token exchange).
  • RFC 9449 (DPoP).
  • OpenID Connect Core 1.0 + Discovery 1.0.
  • OWASP "JWT Best Current Practices".
  • Auth0 "OAuth 2.1: What's New".

Sign in to save your progress and earn badges.