JWT decode.
Decode any JSON Web Token — or sign a new one with HMAC-SHA256/384/512. Verifying RS-/ES- tokens needs a public key parsed from JWK; that's not done in-browser here. Secrets and tokens you paste stay local.
{
"alg": "HS256",
"typ": "JWT"
} {
"sub": "1234567890",
"name": "Ada Lovelace",
"iat": 1714432000,
"exp": 1781632000
} pKR5M7tEif_B6fRm8g3nXzLikUFoNWIZbQ4bB7nHohg
JWT is one member of a five-RFC family.
JWT does not exist in isolation. It is the public-facing member of a family of specifications collectively known as JOSE — JavaScript Object Signing and Encryption — produced by the IETF JOSE working group between 2011 and 2015. Five RFCs are load-bearing here, and understanding which one governs which behavior is the difference between debugging a token problem in five minutes and five hours.
RFC 7519 defines the JWT itself: a compact representation of claims (a JSON object) that can be transmitted between parties. RFC 7515 defines JWS, the JSON Web Signature, which is the actual mechanism most JWTs use under the hood — the three-part header.payload.signature Base64URL string that engineers think of as "a JWT" is technically a JWS Compact Serialization carrying a JWT payload. RFC 7516 defines JWE, JSON Web Encryption, used when the payload itself must be confidential rather than merely authenticated. RFC 7517 defines JWK, JSON Web Key, the JSON representation of cryptographic keys (and JWK Sets, which is what /.well-known/jwks.json endpoints return). RFC 7518 defines JWA, JSON Web Algorithms, the registry of algorithm identifiers like HS256 and ES384. RFC 7797 (unencoded payload) and RFC 8725 (the JWT BCP, December 2020) round out the set with operational guidance.
In practice, a signed JWT you see in an Authorization header is a JWS (7515) carrying a JWT (7519) payload, signed with an algorithm registered in JWA (7518), verified against a key that may have been retrieved as a JWK (7517). Most engineers say "JWT" when they mean any or all of these things, and that elision is fine until something breaks at a layer boundary — for instance, a JWE-wrapped JWT will not decode in a tool expecting a JWS, and a key rotation event is really a JWK Set change rather than a JWT change.
Pin the algorithm. Always.
The alg header value tells a verifier which algorithm to use, and choosing it well — and refusing to be talked out of that choice by an attacker — is the single highest-use security decision you make with JWTs.
| alg | Family | Key type | Notes |
|---|---|---|---|
| HS256/384/512 | HMAC-SHA-2 | Symmetric secret | Same key signs and verifies; only safe within one trust boundary |
| RS256/384/512 | RSA PKCS#1 v1.5 | RSA keypair | Ubiquitous, but PKCS#1 v1.5 is discouraged for new designs |
| PS256/384/512 | RSA-PSS | RSA keypair | The modern RSA choice; randomized signatures |
| ES256/384/512 | ECDSA P-256/384/521 | EC keypair | Compact signatures, fast verification |
| EdDSA | Ed25519, Ed448 | Edwards-curve | Fastest, deterministic, no nonce footguns; RFC 8037 |
| none | — | — | Do not accept. Ever. |
HS256 is appropriate when one party signs and verifies — a single backend issuing tokens it later checks itself. The moment a second party needs to verify, you want an asymmetric algorithm so the secret never leaves the issuer. RS256 dominates the installed base because it shipped first and every library supports it, but new systems should prefer PS256 (RSA-PSS, randomized) or, better, ES256 or EdDSA for smaller signatures and faster verification. Ed25519 in particular has become the recommended default in 2024-era guidance from projects like the OpenID Foundation.
The alg=none CVE is the canonical lesson. The original JWT spec listed none as a valid algorithm for unsigned tokens, and many early libraries (including node-jsonwebtoken before 4.2.2 in 2015 and several Java implementations through 2016) would happily verify a token whose header said alg: none by skipping verification entirely. An attacker stripped the signature, set alg to none, and impersonated anyone. The fix is to pin algorithms on the verifier side: never trust the header.
The other algorithm-confusion attack worth naming is RS256-to-HS256 key confusion, disclosed against multiple libraries in 2015. If a verifier accepts both RS256 and HS256 and selects the algorithm from the token header, an attacker takes the public RSA key (which is, by definition, public), uses it as the HMAC secret, signs a token with alg: HS256, and the verifier — interpreting the public key as a symmetric secret — accepts it. Pin to one algorithm family per key.
jose (panva): jwtVerify(token, key, { algorithms: ['ES256'] }). jsonwebtoken (auth0): jwt.verify(token, key, { algorithms: ['RS256'] }). java-jwt (auth0): JWT.require(Algorithm.RSA256(pub, null)).build().verify(token) — the algorithm is bound to the verifier object rather than read from the token.
Where to keep the state.
The decision to use JWT or an opaque session token is not primarily a security decision; it is an architectural one about where you keep state. An opaque session token is a random identifier — usually 128+ bits from a CSPRNG — that points to a server-side row containing the user's identity, scopes, and expiry. Revocation is DELETE FROM sessions WHERE id = ?. Privilege changes propagate on the next request because the server reads fresh state every time. The cost is a database or cache lookup per request and a session store that has to scale with traffic.
A JWT is the inverse: the token itself carries the claims, signed so the verifier can trust them without consulting any shared store. Verification is a public-key operation against a cached key, microseconds rather than milliseconds. The cost is that you cannot easily revoke a token that has already been issued — short of maintaining a denylist (which reintroduces the state you were trying to avoid) or rotating the signing key (which logs everyone out).
The standard compromise is the refresh-token pattern: a short-lived JWT (5–15 minutes) for API calls, paired with a long-lived opaque refresh token (days to weeks) stored server-side. The JWT gives you fast stateless verification on the hot path; the refresh token gives you a single chokepoint where you can revoke, audit, and rotate. OAuth 2.0 (RFC 6749) and OpenID Connect codify this pattern.
For a single monolithic application with one database, JWT is usually overkill — you have a session store right there and revocation is free. JWTs earn their complexity when trust must cross a boundary: a frontend talking to a microservice fleet without each service hitting an auth database, an API gateway forwarding identity to downstream services, federated identity (Google, Okta, Auth0) where the identity provider and resource server are different organizations, or service-to-service trust where mTLS is too heavyweight.
Seven claims you should know by name.
| Claim | Meaning | Common bug |
|---|---|---|
| iss | Issuer | Not pinning to expected issuer |
| sub | Subject (the principal) | Treating as user-friendly identifier |
| aud | Audience (intended recipient) | Not verifying, or verifying as string when it is a list |
| exp | Expiration time (NumericDate) | Off-by-clock-skew rejections |
| nbf | Not before | Same skew problem in reverse |
| iat | Issued at | Used for max-age policy without skew |
| jti | JWT ID (unique) | Not used for replay protection when it should be |
All time claims are NumericDate — seconds since the Unix epoch as a JSON number, not an ISO 8601 string. The clock-skew problem bites everyone eventually: two servers whose clocks drift by 45 seconds will reject each other's freshly-issued tokens. RFC 7519 §4.1.4 explicitly permits "some small leeway, usually no more than a few minutes" — 30 to 60 seconds is the conventional setting, configurable in every mature library (clockTolerance in jose, clockTolerance in jsonwebtoken, acceptLeeway in java-jwt).
The audience bug is subtler. aud may be a string or an array of strings (RFC 7519 §4.1.3). A verifier that does if (token.aud === 'my-api') silently fails when the issuer changes the claim to ["my-api", "my-mobile-app"]. Worse, code that does not check aud at all will accept a token issued for a completely different service that happens to share a signing key.
Custom claims should be namespaced. The convention from OpenID Connect is to use a URI you control: https://example.com/role rather than bare role. This avoids collisions if your token later passes through a system that adds its own claim with the same name. The IANA JSON Web Token Claims Registry lists standardised public claims — check it before inventing one.
Patterns from real bug bounties.
The payload is Base64URL-encoded JSON, readable by anyone who possesses the token. Treat the contents as public.
Leaking tokens. Putting a JWT in a URL query parameter (?token=…) means it lands in web server access logs, browser history, Referer headers sent to third parties, and proxy logs. Use Authorization: Bearer headers. Scrub tokens from application logs and error reporters — Sentry, Datadog, and Rollbar all have allowlist/denylist configuration for exactly this.
Cross-environment key reuse. Staging and production must not share signing keys. A staging-environment compromise should not yield production tokens. The same applies to test fixtures committed to source control.
Missing aud verification. Already covered above, but worth repeating because it is the most common finding in JWT pentests after alg confusion.
No key rotation. Signing keys should rotate on a schedule (quarterly is typical) and immediately on suspected compromise. JWK Sets make this manageable: the issuer publishes the current and previous public keys at a jwks_uri, and the verifier selects by kid (key ID) header.
No refresh-token rotation. If refresh tokens are long-lived and reusable, a stolen refresh token is a long-lived account takeover. RFC 6749 §10.4 and the OAuth 2.0 Security BCP (RFC 9700, January 2025) require refresh-token rotation: each use issues a new refresh token and invalidates the old one, with reuse detection triggering revocation of the entire token family.
PII in the payload. Email addresses, phone numbers, full names, and account balances do not belong in a JWT that may be cached by intermediaries, logged by clients, or persisted in browser storage. If you must transport sensitive data, use JWE (RFC 7516) — or, far more commonly, keep the token small and look up the rest server-side from sub.
When NOT to use a JWT.
JWTs are bearer tokens. Anyone with the string is the user. They cannot be revoked unless you keep server-side state — and at that point you've reinvented sessions. For session management on a single application, opaque tokens stored in a Redis-backed session store are simpler, smaller, easier to revoke, and don't leak claims to the browser. JWTs earn their keep at trust boundaries: a third-party identity provider issuing a signed assertion that a downstream service can verify offline. That's the OIDC ID token. That's a service-to-service auth token signed by your auth service. Inside one application, you usually don't need them.
The standard pattern is: 5–15 minute access JWTs (carried on every API call) plus longer-lived opaque refresh tokens (stored once, exchanged for new access tokens). The short access lifetime bounds revocation latency without making revocation a hot path; the opaque refresh token can be revoked instantly because you control the store.