Tokens and formats: JWT, opaque, JWE, and PASETO

The building blocks the protocols depend on — JWT structure (header, payload, signature), self-contained vs opaque/reference tokens, JWE for confidential payloads, PASETO as a safer alternative that avoids algorithm confusion, and how to validate a token: signature, expiry, audience, issuer, and revocation.

The currency the protocols spend

Across this module, one artifact keeps reappearing: the token. OIDC issues an ID token; OAuth issues an access token; SAML issues an assertion. They’re all tokens — packets of trusted claims that one party mints and another verifies. This article opens the token itself: how it’s built, the formats you’ll meet, and — most importantly — how to validate one without getting burned.


Most modern tokens are bearer tokens: whoever holds the token may use it, like cash. That makes their format and their validation security-critical. A token your service trusts after sloppy validation is a forged banknote you accepted because you didn’t check the watermark. So we’ll spend most of our time on structure and verification, and on the formats — JWT, opaque, JWE, PASETO — each of which makes a different trade between convenience, confidentiality, and safety.



JWT: the self-contained workhorse

The JSON Web Token (RFC 7519) is the dominant token format on the modern web. It’s self-contained: everything a verifier needs travels inside the token, so it can be validated locally without a call back to the issuer. A JWT is three base64url-encoded parts joined by dots — header.payload.signature:


Anatomy of a JWT. The compact token is a single string of three dot-separated base64url segments, color-coded: the first segment is the Header, the second the Payload, the third the Signature. Below, each segment is shown decoded: the Header is JSON with alg (signing algorithm) and kid (key id); the Payload is JSON with claims iss, sub, aud, exp, iat; the Signature is the result of signing the encoded header and payload with the issuer's key, and is what a verifier checks.
A JWT is header.payload.signature, each base64url-encoded. The header and payload are readable by anyone (signed, not secret); the signature is what proves the token is authentic and untampered.
Anatomy of a JWT. The compact token is a single string of three dot-separated base64url segments, color-coded: the first segment is the Header, the second the Payload, the third the Signature. Below, each segment is shown decoded: the Header is JSON with alg (signing algorithm) and kid (key id); the Payload is JSON with claims iss, sub, aud, exp, iat; the Signature is the result of signing the encoded header and payload with the issuer's key, and is what a verifier checks.

The three parts each do one job:

  • Header — metadata about the token itself: alg (the signing algorithm, e.g. RS256, ES256) and usually kid (the key ID, so the verifier knows which public key to use), plus typ.
  • Payload — the claims: statements like iss (issuer), sub (subject), aud (audience), exp (expiry), iat (issued-at). These are the actual content the verifier acts on.
  • Signature — the issuer signs the encoded header and payload with its key; the verifier recomputes and compares. A valid signature means this came from the issuer and nothing was altered.


JWS: signed, the default

What we’ve described — a signed JWT — is technically a JWS (JSON Web Signature, RFC 7515). Signing can be:

  • Asymmetric (RS256, ES256): the issuer signs with a private key; verifiers check with the public key (fetched from the issuer’s JWKS endpoint). This is the norm for federation — many parties can verify, only one can mint.
  • Symmetric (HS256): a shared secret both signs and verifies. Simpler, but every verifier can also forge, so it only suits a single trust domain.

Registered, public, and private claims

The payload’s claims come in three flavors, and knowing the distinction prevents collisions:

  • Registered claims — the standard, reserved names defined in RFC 7519: iss, sub, aud, exp, nbf (not-before), iat (issued-at), and jti (a unique token ID, useful for deny-lists and replay detection). Short names, well-understood semantics — use them as intended.
  • Public claims — names registered in the IANA registry or namespaced by URI to avoid clashes (for example OIDC’s email, name).
  • Private claims — names you and the other party agree on privately (tenant_id, role). Fine within your own systems, but prefix them to avoid colliding with future registered names.

The signing algorithms you’ll meet

The alg header names how the token was signed, and a few values cover almost everything:

  • RS256 — RSA signatures; ubiquitous, widely supported, the safe default for federation.
  • ES256 — ECDSA on the P-256 curve; smaller keys and signatures than RSA at equivalent strength, increasingly preferred.
  • EdDSA (Ed25519) — modern, fast, misuse-resistant; the best choice where supported.
  • HS256 — HMAC with a shared secret; only for a single trust domain (the verifier can also mint).

The one value you should never accept is none — a JWT that claims it needs no signature. Pin the algorithms you expect and reject everything else; letting the token choose is the root of the attacks we’ll cover under PASETO and validation.


JWE: when the payload must stay secret

Sometimes the claims themselves are sensitive and shouldn’t be readable by the client or any intermediary. JWE (JSON Web Encryption, RFC 7516) is a JWT whose payload is encrypted, not just signed — only the intended recipient, holding the right key, can decrypt it. A JWE has five parts instead of three (it carries the encrypted key and initialization vector).


The decision is clean: a plain (JWS) token answers “can I trust this?”; a JWE additionally answers “who else can read this?”. Use JWE when a token must pass through a party that shouldn’t see its contents — for example an access token routed through a browser that carries attributes the client must not inspect. In practice JWE is far less common than JWS, because most tokens carry no secret claims and the simplicity of a readable, signed token is preferred.


Self-contained vs opaque: the validation trade-off

Cutting across format is a deeper architectural choice: is the token self-contained (a JWT the verifier reads locally) or opaque (a reference the verifier must look up)?


flowchart TB
  accTitle: Self-contained vs opaque token validation
  accDescr: A resource server receives a token and branches on its type. If it is a self-contained JWT, the server validates it locally using the issuer's public key and the claims inside it, with no network call — fast, stateless, but valid until it expires. If it is an opaque reference token, the server must call the issuer's introspection endpoint over the network to ask whether the token is active and what scopes it carries — this adds latency and coupling but allows the issuer to revoke the token instantly.
  T[Token arrives at resource server] --> Q{Self-contained or opaque?}
  Q -->|Self-contained JWT| L[Validate locally<br/>signature + claims, no network call]
  L --> L2[Fast & stateless<br/>but valid until exp]
  Q -->|Opaque reference| I[Call issuer introspection endpoint]
  I --> I2[Adds latency + coupling<br/>but supports instant revocation]
Two validation models: a self-contained JWT is verified locally with the issuer's public key; an opaque token forces a call to the issuer's introspection endpoint. Statelessness vs. instant revocation.

  • Self-contained (JWT): the verifier checks the signature and claims locally. Pro: fast, stateless, no dependency on the issuer at request time — ideal for high-throughput APIs and microservices. Con: hard to revoke. Because verification is offline, the token is valid to everyone until exp; you can’t easily “take it back.”
  • Opaque (reference token): the token is just a random identifier; the verifier calls the issuer’s introspection endpoint (RFC 7662) to learn if it’s active and what it grants. Pro: the issuer is the live source of truth, so revocation is instant. Con: a network call per validation, and tight coupling to the issuer’s availability.

The practical pattern many systems adopt: short-lived JWTs for everyday API calls (accept the brief revocation gap in exchange for speed) and opaque tokens with introspection for the most sensitive resources (pay the latency for instant control). It’s the same speed-vs-control tension we saw between JIT and SCIM, now at the token layer.


Where these tokens actually show up

It helps to map these formats back onto the protocols you’ve already met, because each made a deliberate choice:

  • OIDC ID token → always a signed JWT (JWS). It’s meant for the client to read, so self-contained and readable is exactly right.
  • OAuth access tokenJWT or opaque, at the provider’s discretion. Some issue self-contained JWTs (fast for resource servers); others issue opaque tokens and expect introspection (instant revocation). The resource server should treat it as opaque unless it’s explicitly an audience of a JWT.
  • OAuth refresh token → almost always opaque and long-lived; it lives only between the client and the authorization server, which is the one party that needs to look it up.
  • SAML assertion → a signed token too, just in XML rather than JSON — same idea (claims + signature), different serialization, from an earlier era.

The lesson: “token” isn’t one thing. The same validation instincts — verify the signature, check who it’s for, check when it’s valid — apply across all of them, whether the bytes are JSON or XML, self-contained or a reference.


PASETO: a safer alternative to JWT

JWT’s flexibility is also its weakness. Because the token declares its own algorithm in the header, a careless verifier can be tricked — the notorious alg:none attack (accept a token that claims to need no signature) and algorithm-confusion attacks (trick a server into verifying an RS256 token with the public key as if it were an HS256 secret). These are implementation failures, but the format invites them.


PASETO (Platform-Agnostic Security Tokens) is a modern response. Its core idea: don’t let the token negotiate its own algorithm. Each PASETO version pins a single, vetted set of cryptographic primitives, and the mode is one of two purposes:

  • local — symmetric, encrypted-and-authenticated (shared key); the payload is confidential.
  • public — asymmetric signed (like a JWS); the payload is readable but verifiable.

Because the algorithm is fixed by the version, alg:none and algorithm confusion simply don’t exist as attack surface. PASETO trades JWT’s vast configurability for safe-by-default behavior. JWT still dominates by ecosystem and tooling — its library and provider support is everywhere, which is a real reason to choose it — but PASETO is worth knowing as the design that learned from JWT’s scars, and as a reminder that a format can make a whole class of bugs impossible rather than merely discouraged. When you do reach for JWT, you’re opting into flexibility you must then secure with disciplined validation.


Validating a token: the checklist that is the security

A token is only as trustworthy as the verification behind it, and most token vulnerabilities are validation that was skipped or done wrong. Validate every token against this checklist, in order:


flowchart TB
  accTitle: Token validation checklist
  accDescr: A received token passes through a sequence of checks, and failing any one rejects it. First, verify the signature against the issuer's public key, explicitly rejecting alg:none and unexpected algorithms. Then check the expiry claim exp, along with not-before and issued-at, allowing only minimal clock skew. Then confirm the audience claim aud names this service. Then confirm the issuer claim iss is a trusted provider. Finally, check revocation status via short lifetimes, a deny list, or introspection. Only a token passing all checks is accepted.
  S[Token received] --> SIG{Signature valid?<br/>reject alg:none}
  SIG -->|no| R[Reject]
  SIG -->|yes| EXP{Not expired?<br/>exp / nbf / iat}
  EXP -->|no| R
  EXP -->|yes| AUD{Audience is me?<br/>aud}
  AUD -->|no| R
  AUD -->|yes| ISS{Issuer trusted?<br/>iss}
  ISS -->|no| R
  ISS -->|yes| REV{Not revoked?}
  REV -->|no| R
  REV -->|yes| OK[Accept]
Token validation is a gauntlet: fail any check and the token is rejected. The order matters — verify the signature first, because everything else is only meaningful once you trust the token's contents.

  • Signature — verify against the issuer’s key, selected by kid from the JWKS. Reject alg:none and pin the expected algorithm so the token can’t downgrade itself.
  • Expiryexp must be in the future; honor nbf (not-before) and iat, and allow only a small clock-skew window (a minute or two), not hours.
  • Audience (aud) — the token must be minted for you. Skipping this lets a token issued for one service be replayed against another — a recurring real-world breach.
  • Issuer (iss) — must be a provider you explicitly trust, matched against an allow-list, not whatever the token claims.
  • Revocation — the hard one: a valid signature doesn’t prove the token wasn’t revoked. Cover it with short lifetimes, a deny list of revoked IDs, or introspection for sensitive resources.

Operational hygiene

Beyond per-request validation, a few practices keep a token system healthy:

  • Mind where tokens live. A token is a bearer credential — store it carefully. For browser apps, prefer an HttpOnly, Secure, SameSite cookie or in-memory storage over localStorage, which is readable by any script and a prime XSS target.
  • Keep payloads lean and public-safe. Put only what verifiers need; never secrets or sensitive PII in a (signed, readable) JWT. Minimize claims — every claim is something a verifier might wrongly trust.
  • Rotate signing keys. Publish keys via JWKS with kids so you can roll to a new key without downtime; retire old keys on a schedule. Key rotation is also your blunt revocation lever — roll the key and every token signed with it dies.
  • Short lifetimes + refresh rotation. Short access-token lifetimes shrink the revocation gap; pair them with rotating refresh tokens (from OAuth) so a stolen token’s value decays fast.
  • Consider sender-constraining. Bearer tokens are stealable by definition. Binding a token to its legitimate sender (so a stolen token is useless elsewhere) is exactly what DPoP and mTLS do — we’ll meet them in the next domain’s frontier topics.


Tokens vs. server-side sessions

A fair question underlies all of this: why use tokens at all, instead of the classic web pattern of a session cookie pointing at server-side session state? The answer is the same trade-off in another costume. A server-side session is the ultimate opaque token — the cookie is just an ID, all state lives on the server, and revocation is trivial (delete the row). It’s simple and instantly revocable, but it requires shared session storage every request must reach, which strains across many services and regions.


A self-contained token flips that: state travels in the token, so any service can verify it with just a public key — no shared session store, no central lookup, which is what makes tokens the natural fit for distributed systems, APIs, and microservices. The price, again, is revocation. So the “tokens vs sessions” debate isn’t really separate from “self-contained vs opaque” — it’s the same axis. Many systems pragmatically combine them: a stateful session for the user’s own app, and short-lived tokens for calls out to APIs and services.


The formats at a glance

FormatReadable?ValidationUse it for
JWT / JWSYes (signed, not secret)Local, offlineSelf-contained tokens; ID tokens, many access tokens
JWENo (encrypted)Local, with decryption keyTokens whose claims must stay confidential
Opaque / referenceNothing to readIssuer introspection callInstant revocation; refresh tokens, sensitive resources
PASETODepends on mode (public readable, local encrypted)Local, fixed algorithmNew systems wanting safe-by-default tokens

There’s no single “best” — the right pick depends on the two questions from the start: who may read it, and how is it validated. A high-throughput API leans to self-contained JWTs; a system that must kill sessions instantly leans to opaque; a payload with secrets needs JWE; a greenfield build allergic to footguns considers PASETO.


Recap

Tokens are the trusted packets the protocols pass around:


  1. JWT is the self-contained workhorse — header.payload.signature, base64url-encoded, validated locally. Signed (JWS), it’s readable by anyone, so never put secrets in it.
  2. JWE encrypts the payload for confidentiality, when intermediaries shouldn’t read the claims; JWS only guarantees authenticity and integrity.
  3. Self-contained vs opaque is the deep trade-off: JWTs are fast and stateless but hard to revoke; opaque/reference tokens need an introspection call but revoke instantly — the very same axis as stateless tokens versus stateful server-side sessions.
  4. PASETO removes JWT’s algorithm-negotiation footguns by pinning algorithms per version — no alg:none, no algorithm confusion.
  5. Validation is the security: signature (reject alg:none), expiry, audience, issuer, revocation — in that order, every time.
  6. Hygiene: store tokens safely, keep payloads lean and public-safe, rotate keys, keep lifetimes short, and consider sender-constraining — small disciplines that together shrink the blast radius of any token that does leak.


Hands-on exercises

These are practical — do them, don’t just read them:

  1. Decode a JWT. Grab a real JWT (from a “Sign in with…” flow, or your browser’s session) and paste it into a decoder, or base64url-decode each segment by hand. Identify the alg and kid in the header and the iss, aud, exp in the payload — and confirm you could read every claim without any key.
  2. Spot the storage risk (use case). An SPA stores its access token in localStorage. Explain the concrete attack this enables, and propose two safer alternatives with their trade-offs.
  3. Choose the token model (use case). For each, pick self-contained JWT or opaque + introspection and justify it: a high-throughput public API; a banking app where a stolen session must be killable within seconds; a fleet of microservices behind one gateway.
  4. Break a validator (use case). A service validates JWTs but trusts the alg field from the header and has no aud check. Describe two distinct forgeries this enables and the exact fixes.
  5. Argue JWT vs PASETO (use case). A team is choosing a token format for a new internal service. Make the two-sentence case for PASETO and the two-sentence case for sticking with JWT, then state which you’d pick and why.
  6. Pick the format (use case). For each, choose JWT, JWE, or opaque and justify it: an access token that must carry a user’s clearance level no client should read; a session token for a bank that must be revocable within seconds; the ID token for a standard “Sign in with…” web app.

Three questions to test yourself

  1. Why can a signed JWT be read by anyone who holds it, and what is the correct format to use when the claims must be confidential?
  2. Explain the revocation trade-off between self-contained and opaque tokens, and describe two practical ways to mitigate the JWT side of it.
  3. What is the alg:none attack, what makes JWT susceptible to it, and how does PASETO’s design make it impossible?

Frequently asked questions

What is a JWT (JSON Web Token)?

A JWT is a compact, self-contained token made of three base64url-encoded parts separated by dots — header, payload, and signature. The header names the signing algorithm, the payload carries claims (who the token is about, who it's for, when it expires), and the signature lets a recipient verify it wasn't tampered with. Because it's self-contained, a service can validate it locally without calling the issuer.

What is the difference between a JWT and an opaque token?

A JWT is self-contained — the recipient validates it locally by checking the signature and claims, with no call back to the issuer. An opaque (reference) token is just a random identifier; the recipient must call the issuer's introspection endpoint to learn whether it's valid and what it grants. JWTs are fast and stateless but hard to revoke before expiry; opaque tokens add a lookup but allow instant revocation.

What is the difference between JWS and JWE?

JWS (JSON Web Signature) is a signed token: anyone can read the payload, but the signature proves it's authentic and unaltered — this is what people usually mean by 'a JWT.' JWE (JSON Web Encryption) is an encrypted token: the payload is confidential and only the intended recipient can read it. Use JWS for integrity and authenticity; use JWE when the contents themselves must stay secret.

What is PASETO and why use it instead of JWT?

PASETO (Platform-Agnostic Security Tokens) is a modern token format designed to avoid JWT's footguns — above all 'algorithm confusion' and the infamous alg:none attack. Instead of letting the token declare its own algorithm, PASETO pins a small set of vetted algorithms per protocol version, so a recipient can't be tricked into accepting a forged or unsigned token. It trades JWT's flexibility for safer defaults.

How do you validate a token?

Check, in order: the signature (against the issuer's public key, and reject alg:none and unexpected algorithms), the expiry (exp, plus nbf/iat with minimal clock skew), the audience (aud must be you), and the issuer (iss must be the provider you trust). For revocation — the hard case — rely on short lifetimes, an introspection call, or a deny list, since a valid signature alone doesn't mean the token wasn't revoked.

Can a JWT be revoked before it expires?

Not easily — that's the core trade-off of self-contained tokens. A JWT is valid to any verifier until its exp passes, because verification is local and offline. Practical answers are keeping access-token lifetimes short (minutes), maintaining a server-side deny list of revoked token IDs, switching sensitive resources to opaque tokens with introspection, or rotating signing keys to invalidate broad sets at once.