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

The building blocks the protocols depend on: JWT structure (header, payload, signature), self-contained versus 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.

A string in a support ticket

David works on the API team. A customer opens a support ticket and, trying to be helpful, pastes in the long string their app sent with the failing request: three blocks of letters and numbers separated by dots. David drops it into a decoder out of habit and there it all is, in plain JSON: the customer’s email, their tenant, their role, the expiry time. No key, no password, nothing. He didn’t break anything; that’s simply how the token works. The ticket is now a copy of a live credential sitting in the support system until it expires.


That string was a token, and it’s the artifact that keeps reappearing across this module. OIDC issues an ID token, OAuth issues an access token, SAML issues an assertion. They’re all packets of trusted claims that one party mints and another verifies. This article opens the token up: how it’s built, the formats you’ll run into, and above all how to validate one without getting burned.


Most modern tokens are bearer tokens: whoever holds one can use it, like cash. That makes their format and their validation security matters. A token your service trusts after sloppy validation is a forged banknote you took because you didn’t check the watermark. For any token you design or accept, two questions explain almost everything that follows: who can read it, and how is it validated, locally or by asking the issuer?


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 it, so it can be checked locally without calling back to the issuer. A JWT is three base64url-encoded parts joined by dots, header.payload.signature.


Anatomy of a JWT A JWT is header.payload.signature, each base64url-encoded. Header and payload are readable (signed, not secret); the signature proves authenticity and integrity. Connectors map each segment of the compact string to the card that decodes it, and each card separates its decoded code from a plain-language note in the footer. one compact string: header . payload . signature eyJhbGciOiJSUzI1Ni . eyJpc3MiOiJpc3N1Z . SflKxwRJSMeKKF2QT4 Header "alg": "RS256" "kid": "a1b2c3" "typ": "JWT" which key & algorithm to verify Payload "iss": issuer "sub": user-id "aud": your-app "exp": 1718000300 "iat": 1718000000 the claims (readable!) Signature sign( b64(header) + "." + b64(payload), issuer key ) verifier checks this → authentic & untampered
A JWT is header.payload.signature, each part base64url-encoded. Anyone can read the header and payload (signed, not secret); the signature is what proves the token is authentic and unaltered.

Each part has one job. The header describes the token itself: alg, the signing algorithm (RS256, ES256 and so on), usually kid, the key ID that tells the verifier which public key to use, and typ. The payload carries the claims, statements like iss (issuer), sub (subject), aud (audience), exp (expiry), and iat (issued at), which are what the verifier actually acts on. The signature is the issuer’s signature over the encoded header and payload; the verifier recomputes and compares it, and a match means the token came from the issuer and nothing was changed.


This is what surprised David. Base64url is an encoding, not encryption. The header and payload of an ordinary signed JWT can be read by anyone holding the token: the browser, the logs, a support ticket, an attacker. The signature protects integrity and authenticity, not confidentiality. Never put a secret, a password, or sensitive personal data in a JWT payload unless you’re using JWE, which we’ll get to. Treat the payload as public.


JWS: signed, the default

What we’ve described, a signed JWT, is technically a JWS (JSON Web Signature, RFC 7515). The signing can be asymmetric or symmetric. With asymmetric algorithms (RS256, ES256), the issuer signs with a private key and verifiers check with the public key, fetched from the issuer’s JWKS endpoint. That’s the norm in federation: many parties can verify and only one can mint. With symmetric algorithms (HS256), one shared secret both signs and verifies. It’s simpler, but every verifier can also forge, so it only suits a single trust domain.


Registered, public, and private claims

Claims come in three kinds, and knowing them avoids collisions. Registered claims are the standard reserved names from RFC 7519: iss, sub, aud, exp, nbf (not before), iat (issued at), and jti, a unique token ID that’s useful for deny lists and spotting replays. Public claims are names registered with IANA or namespaced by URI to avoid clashes, such as OIDC’s email and name. Private claims are names you and the other party agree on between yourselves (tenant_id, role). They’re fine inside your own systems, but prefix them so they don’t collide with names registered later.


The signing algorithms you’ll meet

The alg header says how the token was signed, and a handful of values cover nearly everything. RS256, RSA signatures, is everywhere and is the safe default for federation. ES256, ECDSA on the P-256 curve, gives smaller keys and signatures than RSA at the same strength and is increasingly preferred. EdDSA with Ed25519 is modern, fast, and hard to misuse, the best choice where it’s supported. HS256, HMAC with a shared secret, is only for a single trust domain, since the verifier can also mint.


The one value you must never accept is none, a JWT claiming it needs no signature. Fix the algorithms you expect and reject everything else. Letting the token choose is the root of the attacks we’ll see under PASETO and validation.


JWE: when the payload has to stay secret

Sometimes the claims themselves are sensitive and neither the client nor anything in between should be able to read them. JWE (JSON Web Encryption, RFC 7516) is a JWT whose payload is encrypted, not only signed, so only the intended recipient holding the right key can decrypt it. A JWE has five parts instead of three, because it also carries the encrypted key and the initialization vector.


The distinction is simple. A signed token answers whether you can trust it; a JWE also controls who else can read it. Use JWE when a token has to pass through a party that shouldn’t see its contents, for example an access token that travels through the browser carrying attributes the client must not inspect. In practice JWE is far less common than JWS, because most tokens carry nothing secret and a readable signed token is simpler to work with. Had David’s customer’s token carried something truly sensitive, that would have been the argument for it.


Self-contained versus opaque

Underneath the format question sits a deeper design choice: is the token self-contained, a JWT the verifier reads locally, or opaque, a reference the verifier has to 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; it is fast and stateless but stays 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 lets the issuer 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 and 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 checked locally with the issuer's public key; an opaque token forces a call to the issuer's introspection endpoint. Statelessness against instant revocation.

With a self-contained JWT, the verifier checks the signature and claims locally. It’s fast, stateless, and doesn’t depend on the issuer at request time, which suits high-throughput APIs and microservices. The cost is revocation: since verification happens offline, the token is valid to everyone until exp, and there’s no easy way to take it back. With an opaque reference token, the token is just a random identifier, and the verifier calls the issuer’s introspection endpoint (RFC 7662) to find out whether it’s active and what it grants. The issuer is the live source of truth, so revocation is instant. The cost is a network call per validation and a tight dependence on the issuer being up.


Many systems settle on a mix: short-lived JWTs for everyday API calls, accepting a brief revocation gap in exchange for speed, and opaque tokens with introspection for the most sensitive resources, paying the latency for immediate control. It’s the same tension between speed and control we saw between JIT and SCIM, now at the token layer.


Where these tokens actually show up

It helps to map the formats back onto the protocols you already know, because each made a deliberate choice. The OIDC ID token is always a signed JWT; it’s meant for the client to read, so self-contained and readable is exactly right. The OAuth access token can be a JWT or opaque, at the provider’s discretion. Some issue JWTs so resource servers can validate quickly, others issue opaque tokens and expect introspection, and the client should treat it as opaque either way. The OAuth refresh token is almost always opaque and long-lived, and it only travels between the client and the authorization server, the one party that needs to look it up. And the SAML assertion is a signed token too, just in XML instead of JSON: the same idea of claims plus signature, from an earlier era.


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


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 fooled. There’s the notorious alg:none attack, where the server accepts a token that says it needs no signature, and there are algorithm-confusion attacks, where a server is tricked into verifying an RS256 token using the public key as if it were an HS256 secret. Both are implementation mistakes, but the format invites them.


PASETO (Platform-Agnostic Security Tokens) is a modern answer, built on one idea: don’t let the token negotiate its own algorithm. Each PASETO version fixes a single, vetted set of cryptographic primitives, and a token has one of two purposes. local tokens are symmetric, encrypted and authenticated with a shared key, so the payload is confidential. public tokens are signed asymmetrically, like a JWS, so the payload is readable but verifiable.


Because the version fixes the algorithm, alg:none and algorithm confusion simply don’t exist as an attack surface. PASETO gives up JWT’s broad configurability for safe defaults. JWT still dominates through its ecosystem, with library and provider support 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 instead of merely discouraged. When you do choose JWT, you’re taking on flexibility that you then have to secure with careful validation.


Validating a token is the security

A token is only as trustworthy as the checks behind it, and most token vulnerabilities come from validation that was skipped or done wrong. Validate every token in this 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. Order matters. Verify the signature first, because the other checks only mean something once you trust the token's contents.

Start with the signature: verify it against the issuer’s key, chosen by kid from the JWKS, reject alg:none, and fix the expected algorithm so the token can’t downgrade itself. Then the expiry: exp must be in the future, nbf and iat must make sense, and the allowance for clock skew should be a minute or two, not hours. Then the audience: aud must name your service. Skipping this lets a token issued for one service be replayed against another, a mistake behind real breaches. Then the issuer: iss must match an allow list of providers you trust, not whatever the token says about itself. And finally revocation, the hard one, because 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.


Two failures account for a large share of token breaches: accepting a token whose header says alg:none, so anyone can mint one, and reading claims from a token without verifying the signature at all. Base64-decoding is not validation. The JWT Best Current Practices (RFC 8725) exist precisely because these mistakes are so common. Verify first and trust the claims only afterwards.


Operational hygiene

Beyond each request, a few habits keep a token system healthy.


Be careful where tokens live. A token is a bearer credential. In browser apps, prefer an HttpOnly, Secure, SameSite cookie or in-memory storage over localStorage, which any script can read and which is a favorite target for cross-site scripting. And keep tokens out of places they don’t belong, like logs, analytics, and support tickets; David’s team ended up adding a warning to the support form and a filter that masks anything shaped like a JWT.


Keep payloads lean and safe to read. Include only what verifiers need, never secrets or sensitive personal data in a signed, readable JWT. Every extra claim is something a verifier might wrongly rely on.


Rotate signing keys. Publish them through JWKS with kid values so you can move to a new key without downtime, and retire old keys on a schedule. Key rotation is also your blunt revocation lever: roll the key and every token signed with it dies.


Keep lifetimes short and rotate refresh tokens. Short access-token lifetimes shrink the revocation gap, and rotating refresh tokens from OAuth make a stolen token lose its value quickly.


Consider binding tokens to their sender. Bearer tokens can be stolen by definition. Tying a token to its legitimate sender, so a stolen copy is useless anywhere else, is what DPoP and mTLS do, and we’ll meet them among the frontier topics.


Tokens versus server-side sessions

A fair question sits under all of this: why use tokens at all instead of the classic web pattern of a session cookie pointing at state kept on the server? It’s the same trade-off in different clothes. A server-side session is the ultimate opaque token: the cookie is just an ID, all the state lives on the server, and revoking it is trivial, you delete the row. It’s simple and instantly revocable, but every request has to reach shared session storage, which gets hard across many services and regions.


A self-contained token flips that around. The state travels in the token, so any service can verify it with just a public key, with no shared session store and no central lookup. That’s why tokens are the natural fit for distributed systems, APIs, and microservices, and the price, again, is revocation. So the debate between tokens and sessions is the same axis as self-contained versus opaque. Plenty of systems combine them: a stateful session for the user’s own app and short-lived tokens for calls out to APIs and services.


The formats side by side

FormatReadable?ValidationUse it for
JWT / JWSYes (signed, not secret)Local, offlineSelf-contained tokens; ID tokens, many access tokens
JWENo (encrypted)Local, with the decryption keyTokens whose claims must stay confidential
Opaque / referenceNothing to readCall to the issuer’s introspectionInstant revocation; refresh tokens, sensitive resources
PASETODepends on purpose (public readable, local encrypted)Local, fixed algorithmNew systems that want safe defaults

There’s no single best format. The right choice comes back to the two opening questions, who may read it and how it’s validated. A high-throughput API leans toward self-contained JWTs, a system that has to kill sessions instantly leans toward opaque tokens, a payload with secrets needs JWE, and a new build that wants to avoid the classic traps should look at 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), anyone can read it, so never put secrets in it.
  2. JWE encrypts the payload when intermediaries mustn’t read the claims; JWS only guarantees authenticity and integrity.
  3. Self-contained versus opaque is the deep trade-off. JWTs are fast and stateless but hard to revoke; opaque tokens need an introspection call but revoke instantly. It’s the same axis as stateless tokens versus server-side sessions.
  4. PASETO removes JWT’s algorithm negotiation by fixing algorithms per version, so there’s no alg:none and no algorithm confusion.
  5. Validation is the security: signature (rejecting alg:none), expiry, audience, issuer, revocation, in that order, every time.
  6. Hygiene shrinks the damage when a token leaks: store tokens carefully, keep payloads lean, rotate keys, keep lifetimes short, and consider binding tokens to their sender.


Hands-on exercises

These are meant to be done, not just read.


  1. Decode a JWT. Take 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. Find alg and kid in the header and iss, aud, and exp in the payload, and confirm you could read every claim without any key.
  2. Spot the storage risk. A single-page app keeps its access token in localStorage. Explain the concrete attack this allows and propose two safer alternatives with their trade-offs.
  3. Choose the validation model. For each case, pick a self-contained JWT or an opaque token with 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. A service validates JWTs but trusts the alg field from the header and has no aud check. Describe two different forgeries this allows and the exact fixes.
  5. Argue JWT versus PASETO. A team is choosing a token format for a new internal service. Make a two-sentence case for PASETO and a two-sentence case for staying with JWT, then say which you’d pick and why.
  6. Pick the format. Choose JWT, JWE, or opaque for each and justify it: an access token that must carry a user’s clearance level that no client should read; a session token for a bank that must be revocable within seconds; the ID token for an ordinary “Sign in with” web app.

Three questions to test yourself

  1. Why can anyone holding a signed JWT read it, and which format should you use when the claims must stay confidential?
  2. Explain the revocation trade-off between self-contained and opaque tokens, and describe two practical ways to soften the JWT side of it.
  3. What is the alg:none attack, what makes JWT vulnerable to it, and how does PASETO’s design rule it out?

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 that nobody altered it. 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 or reference token is just a random identifier, and the recipient has to 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 they expire; opaque tokens add a lookup but can be revoked instantly.

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. That's 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, and JWE when the contents themselves have to 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 traps, above all algorithm confusion and the infamous alg:none attack. Instead of letting the token declare its own algorithm, PASETO fixes a small set of vetted algorithms per protocol version, so a recipient can't be tricked into accepting a forged or unsigned token. It gives up JWT's flexibility in exchange for safer defaults.

How do you validate a token?

Check, in order: the signature (against the issuer's public key, rejecting alg:none and any unexpected algorithm), the expiry (exp, plus nbf and iat with minimal clock skew), the audience (aud must be you), and the issuer (iss must be a provider you trust). Revocation is the hard case. Rely on short lifetimes, an introspection call, or a deny list, because a valid signature alone doesn't mean the token wasn't revoked.

Can a JWT be revoked before it expires?

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