Ttooleras
Developer Utilities

What a JWT actually is and what you shouldn't put in one

JWTs are signed claims, not secret containers. What they are, when they fit, and what not to put in one.

Tooleras25 min read5,456 words
Advertisement

At 02:13 on a Tuesday, a support engineer pasted a production access token into an internal ticket because the billing API was returning 403 for one customer and nobody could reproduce it locally. The token looked harmless enough. Three long chunks, dots between them, mostly letters and numbers:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InByb2QtbG9naW4tMTcifQ.
eyJzdWIiOiJ1c3JfNDFmMGI5IiwiZW1haWwiOiJtYXJhLnBhdGVsQGV4YW1wbGUtY3VzdG9tZXIuY29tIiwicGhvbmUiOiIrMS00MTUtNTU1LTAxOTgiLCJkb2IiOiIxOTg5LTExLTA0IiwibGFzdDQiOiI4NDIxIiwicGxhbiI6ImZhbWlseSIsImJpbGxpbmdfemlwIjoiOTQxMDciLCJpYXQiOjE3NjIzMzAzMjgsImV4cCI6MTc2MjkzNTEyOH0.
MEUCIQDX-example-signature-bytes

The team had been calling that middle section "encrypted user context" for months. It wasn't encrypted. It was base64url-encoded JSON.

The first 32 decoded payload bytes were not a cipher mystery. They were plain UTF-8:

0x7b 0x22 0x73 0x75 0x62 0x22 0x3a 0x22
0x75 0x73 0x72 0x5f 0x34 0x31 0x66 0x30
0x62 0x39 0x22 0x2c 0x22 0x65 0x6d 0x61
0x69 0x6c 0x22 0x3a 0x22 0x6d 0x61 0x72

That starts with:

{"sub":"usr_41f0b9","email":"mar

The rest of the payload had the customer's email address, phone number, date of birth, billing ZIP code, plan type, and the last four digits of a card. The token was signed with RS256, so nobody could edit it without breaking the signature. But everyone who had the string could read it: the browser, the JavaScript error reporter, the reverse proxy logs, the ticketing system, the vendor search index, the data warehouse table that ingested support events, and the contractor account that had read access to that table for a one-week reporting task.

The damage wasn't movie-hacker dramatic. It was worse in the way real incidents are worse. The team had to rotate tokens, purge logs with mixed success, notify legal, explain why "encoded" had been used in design docs as if it meant "secret", and rewrite an auth middleware that had turned JWTs into portable profile dumps. A phishing attempt against the same customer landed two weeks later with details that looked too close to the leaked fields to feel coincidental.

This is the JWT trap. JWTs look cryptographic, so people assume they're confidential. They're often used for authentication, so people assume they're session storage. They can be verified without a database lookup, so people assume "stateless auth" means no state and no revocation cost. Each of those assumptions is close enough to be tempting and wrong enough to hurt.

This post isn't a replacement for a decode walkthrough. If you only need the mechanics of splitting a token and reading the JSON, the existing JWT decoding guide covers that angle. Here we're looking at the design decision: what a JWT actually promises, what it doesn't promise, when it's a good fit, and what you should refuse to put inside one.

What a JWT actually is

A JSON Web Token is a compact way to carry claims between parties. That definition is almost too small, but it's the useful one. A claim is a statement like "the issuer is https://auth.example.com", "the subject is user_7f3a91", "the audience is https://api.example.com", or "this token expires at this Unix timestamp".

The formal spec is RFC 7519, JSON Web Token (JWT), May 2015. It defines JWT as a claims set represented as JSON and then protected as either a JSON Web Signature or JSON Web Encryption object. JWT itself is the token format. The thing you usually paste into an Authorization: Bearer ... header is more specifically a JWS-form JWT.

A common JWT has three dot-separated parts:

header.payload.signature

Each part is base64url-encoded. Base64url is the URL-safe cousin of base64: + becomes -, / becomes _, and padding = is often omitted. That encoding is transport packaging, not secrecy. If you can copy the string, you can decode the header and payload with a few lines of JavaScript, a terminal command, or a browser-side decoder.

There are three related standards that get blurred together:

JSON Web Signature, RFC 7515, May 2015, or JWS, protects content with a digital signature or a message authentication code. In compact form, a JWS has three parts: protected header, payload, and signature. The payload is readable unless you separately encrypt it. The signature proves integrity and authenticity if the verifier checks it correctly.

JSON Web Encryption, RFC 7516, May 2015, or JWE, encrypts content. In compact form, a JWE has five parts: protected header, encrypted key, initialization vector, ciphertext, and authentication tag. If you see five dot-separated parts, you may be looking at encrypted JOSE content. If you see three parts, you're usually not.

JSON Web Token, RFC 7519, May 2015, or JWT, defines the claims format and how those claims are used inside JWS or JWE. A JWT can be signed, encrypted, both, or in narrow cases unsecured. In production auth, "JWT" almost always means "a signed JWS carrying JWT claims".

That distinction matters. A signed JWT says, "someone with the signing key produced these claims, and the claims weren't changed after signing." It doesn't say, "only the server can read these claims." A JWE can provide confidentiality, but most bearer JWTs in web apps aren't JWEs. They're signed, readable claims.

The usual header:

Authorization: Bearer eyJhbGciOi...

comes from OAuth bearer-token usage, described in RFC 6750, October 2012. A bearer token has the property that whoever possesses it can use it. That's convenient. It's also why leaked tokens are serious even when their payloads are boring.

What's in each part

Here's a real-shaped example with a mock signature. Don't use it as a secret. The point is the shape.

eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtMjAyNi0wNCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzdmM2E5MSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiZXhwIjoxNzcwNDIyNDAwLCJuYmYiOjE3NzA0MTg4MDAsImlhdCI6MTc3MDQxODgwMCwianRpIjoiMDFIVjdUNlNCMEI0WTVSM0s5SzdOMVFLMlAiLCJzY29wZSI6Im9yZGVyczpyZWFkIGludm9pY2VzOnJlYWQiLCJ0aWVyIjoicHJvIn0.mocksignaturebytes

Decoded header:

{
  "alg": "EdDSA",
  "typ": "JWT",
  "kid": "auth-2026-04"
}

The header is metadata for processing the token.

alg says which algorithm was used for the signature or MAC. This field is attacker-controlled until after verification, so a verifier must not blindly obey it. The application should already know which algorithms it accepts for a given issuer and key.

typ is usually JWT. It's a type hint, not a security boundary. Some systems use it to distinguish token kinds, but it shouldn't replace issuer, audience, and purpose checks.

kid is a key ID. It helps the verifier choose the right key from a key set during rotation. It must be treated as an identifier, not as a URL to fetch from arbitrary user input. If your verifier uses kid to read files or construct database queries, you've built a new attack surface into your auth layer.

Decoded payload:

{
  "iss": "https://auth.example.com",
  "sub": "user_7f3a91",
  "aud": "https://api.example.com",
  "exp": 1770422400,
  "nbf": 1770418800,
  "iat": 1770418800,
  "jti": "01HV7T6SB0B4Y5R3K9K7N1QK2P",
  "scope": "orders:read invoices:read",
  "tier": "pro"
}

The payload contains claims. RFC 7519 section 4.1 registers these common claim names:

iss is the issuer. It tells the verifier who minted the token.

sub is the subject. In auth systems, it's usually a stable user ID, service account ID, or workload ID.

aud is the audience. It tells you who the token is for. This is one of the most skipped checks and one of the most useful. A token for the analytics API shouldn't work against the billing API.

exp is expiration time. After this time, the token should be rejected.

nbf is not-before time. Before this time, the token should be rejected.

iat is issued-at time. It helps you reason about age and can support max-age rules.

jti is the JWT ID. It can support replay detection, denylists, audit logs, or one-time token use when you keep state for that purpose.

Everything else is either a public claim defined elsewhere or a private claim agreed on by your application. scope, roles, tenant_id, plan, and permissions are common. They're not magic because they're inside a JWT. They're app data. If they're wrong, stale, too broad, or private, the JWT format won't save you.

The signature is the third part. For HMAC algorithms like HS256, the issuer and verifier share a secret. The signer computes a MAC over:

base64url(header) + "." + base64url(payload)

For asymmetric algorithms like RS256, ES256, PS256, or EdDSA, the issuer signs with a private key and verifiers check with a public key. That lets many services verify tokens without giving every service the power to mint tokens.

Verification is more than "does the signature match?" A production verifier should check the signature, accepted algorithm, key, issuer, audience, expiration, not-before time, token type or purpose when relevant, and any app-specific claims. Decoding is reading. Verification is deciding whether to trust.

Stateless auth isn't no-state auth

"Stateless auth" usually means the resource server can validate an access token without looking up a server-side session row on every request. The token carries enough signed claims for the API to make a local decision: who issued it, who it's about, who it's for, when it expires, and what it's allowed to do.

That can be a real win. A service can validate a token using a cached public key. A gateway can authorize a request even if the session database is slow. A cluster of services can avoid centralizing every permission check behind one hot database.

But the state didn't vanish. It moved.

The signing keys are state. The issuer's JWKS endpoint is state. Key rotation is state. Refresh tokens are state. Logout policy is state. A denylist is state. User suspension is state. Tenant membership is state. Clock skew is operational state with a mean sense of humor.

JWTs are useful when you're comfortable pushing some authorization facts into a short-lived, signed document. They're a poor fit when you need the server to change its mind immediately and globally about a token that has already been issued.

Six JWT misuses that keep showing up

1. Putting private data in a signed JWT

What people try:

A team wants fewer profile lookups, so it puts user data into the access token: email, full name, phone number, date of birth, billing ZIP, account status, feature flags, support tier, maybe the current organization name. Someone says it's fine because the JWT is signed. Someone else says it's fine because the token is "encoded."

Why it breaks:

Signed means integrity, not confidentiality. The payload is visible to the bearer and to every system that sees the bearer token. Browsers, mobile apps, log collectors, reverse proxies, APM tools, crash reporters, request replay tools, customer support plugins, and analytics scripts all become potential readers.

The signature prevents undetected edits. It doesn't hide the JSON. If a token contains email, you've distributed the email. If it contains dob, you've distributed the date of birth. If it contains internal account risk labels, you've distributed those too.

There's also a data freshness problem. If a user changes email, loses a role, or leaves an organization, every already-issued token still says what it said at issue time until it expires or you add revocation state.

What to do instead:

Keep access tokens small and boring. Prefer stable identifiers and authorization facts that the resource server needs right now: sub, iss, aud, exp, scope, tenant_id, maybe a narrow role or permission version. Fetch profile data from an API that can enforce access control and return current values.

If you truly need confidentiality inside the token, use JWE and accept the extra complexity: encryption keys, algorithm choices, key rotation, nested signing and encryption rules, larger tokens, and fewer library paths that everyone on the team understands. Most product sessions don't need JWE. They need less data in the token.

2. Using JWT as a session token when revocation matters

What people try:

They replace server-side sessions with a JWT stored in local storage or a cookie. The JWT has sub, role, and an expiration seven days from now. Logout just deletes the token from the browser. The API accepts any validly signed token until exp.

Why it breaks:

That model can't reliably revoke a token already copied by an attacker, a proxy log, a browser extension, or a compromised device. Deleting the browser's copy doesn't delete every other copy. If a password is reset, an employee is terminated, or an admin clicks "log out all devices", the API still has no reason to reject an otherwise valid token unless it checks state somewhere.

This is the point where teams rediscover server-side state by adding a JWT denylist, a token version column, a jti lookup, a session table, or a cache check. Those aren't bad ideas. They just mean the architecture isn't purely stateless anymore.

What to do instead:

For classic web sessions where immediate logout, device management, and account lockout matter, a server-side session ID in a secure, HttpOnly, SameSite cookie is often the simpler design. The cookie value can be opaque. The server can revoke it by deleting one row.

If you need JWT access tokens, use short-lived access tokens and keep refresh tokens under tighter server-side control. Rotate refresh tokens. Store refresh-token state server-side. Revoke refresh tokens on logout or risk events. Let access tokens live for minutes, not days, so the blast radius of a stolen access token is bounded.

3. Accepting alg: none

What people try:

A verifier decodes the header, sees "alg":"none", and treats the token as intentionally unsecured. Or a developer uses a decode function where a verify function was required. Or a library defaults to accepting too much because test tokens used none during early development.

Why it breaks:

An unsecured JWT has no signature. If your auth layer accepts it, an attacker can make their own payload:

{
  "sub": "attacker",
  "role": "admin",
  "exp": 4102444800
}

Then they can send it as a bearer token and hope your server confuses parsing with trust.

Unsecured JWTs exist in RFC 7519 section 6, but that doesn't make them acceptable for your app's authentication boundary. RFC 8725, JSON Web Token Best Current Practices, February 2020, says libraries shouldn't consume none unless the caller explicitly asks for it, and applications should verify algorithms rather than trusting the token to choose.

CVE naming around the 2015 JWT bugs is messy in blog memory. CVE-2015-9235 is often mentioned in the same conversation as the famous alg: none bypass writeups, but the current NVD text describes node-jsonwebtoken before 4.2.2 accepting an asymmetric-to-HMAC algorithm confusion path. The broader lesson is the same: don't let an untrusted header decide what security check happens.

What to do instead:

Use your library's verification API, not its decode API, for auth decisions. Configure an explicit allowlist of algorithms. Reject none in production. Bind acceptable algorithms to the issuer and key material. Tests can have fake keys; they don't need unsigned tokens in the same code path your API uses.

4. Algorithm confusion attacks

What people try:

The application supports RS256 because the identity provider signs with a private RSA key. The API has the public key. The developer calls a verify function and passes that public key. The verify function reads alg from the token header and decides what to do.

Why it breaks:

If the verifier accepts both RS256 and HS256 in the same path, an attacker may be able to change the header to HS256 and use the RSA public key as an HMAC secret. Public keys are public. If the library treats that public key bytestring as a symmetric secret, the attacker can sign their own token that passes verification.

This isn't theoretical. CVE-2015-9235 records a node-jsonwebtoken verification bypass in this family. CVE-2023-48223 records a fast-jwt algorithm confusion issue where a crafted HS256 token signed with a victim application's public RSA key could be accepted in specific RS256/public-key configurations. CVE-2023-48238 records a similar json-web-token issue where the algorithm used for verification was taken from the unverified token.

What to do instead:

Treat algorithm, key type, issuer, and audience as a policy bundle. For this issuer and this kid, the allowed algorithm is exactly one thing. Don't mix HMAC and asymmetric algorithms in the same verifier configuration. Don't pass arbitrary key bytes into a function that can reinterpret them under a different algorithm family.

If you use JWKS, each key should have key type and intended algorithm metadata, and your verifier should enforce it. If you control both sides, choose one modern algorithm for the token class and stick with it.

5. Using JWTs for CSRF tokens

What people try:

They create a JWT with sub, session_id, and iat, put it in a hidden form field, and call it a CSRF token. It feels more secure than a random string because it's signed. Sometimes the same JWT is also stored in a cookie so the server can compare values.

Why it breaks:

CSRF protection isn't about proving that a JSON payload was signed by your server. It's about proving that the state-changing request came from a page or client flow your server intended, not from an attacker's site riding the victim's cookies.

A CSRF token should be unpredictable, bound to the user's session or request context, and checked on the server. A signed JWT can be unpredictable if it includes randomness, but the signature adds little to the core property. Worse, teams sometimes make the JWT reusable for too long, put user data in it, expose it in URLs, or validate it without binding it to the current session.

What to do instead:

Use boring CSRF defenses. For cookie-authenticated browser apps, set cookies with SameSite=Lax or SameSite=Strict where your flows allow it. Use a synchronizer token stored server-side or a carefully implemented double-submit cookie pattern bound to the session. Validate Origin and Referer for state-changing requests where practical. A random 128-bit token tied to the session is a better CSRF token than a decorated JWT.

6. Long expiry with no refresh mechanism

What people try:

They set exp to 30 days because frequent login prompts annoy users. Mobile apps get 90 days. Internal tools get one year because "it's behind VPN." There's no refresh token, no rotation, no reuse detection, and no easy way to see which devices hold tokens.

Why it breaks:

A bearer token with a long expiry is a password with a timestamp. If it's stolen, the attacker doesn't need to break crypto. They only need to keep presenting it. Long-lived JWTs also preserve stale authorization. If someone is removed from a tenant today, a token minted yesterday may still carry the old role.

Long expiry also hides operational mistakes. If you accidentally put too much data in a token, grant the wrong scope, or sign with a key that later leaks, every long-lived token becomes a cleanup problem.

What to do instead:

Use short-lived access tokens and a separate refresh flow. For browser apps, keep refresh tokens out of JavaScript when you can: HttpOnly, Secure, SameSite cookies are usually better than local storage. Rotate refresh tokens on use. Detect refresh-token reuse. Apply idle timeouts and absolute session lifetimes. Reissue access tokens with current claims instead of letting old claims linger for weeks.

When JWTs are the right tool

JWTs get a lot of blame because they're used in places where an opaque session ID would be kinder. But they're not a bad format. They're a sharp format. They work well when the trust boundary and lifetime match the shape of the token.

Microservice-to-microservice auth is a good fit. A workload identity provider can sign a short-lived token for service A to call service B. The token has iss, sub, aud, exp, and narrow scopes. Service B can verify the signature locally with a cached public key and reject tokens meant for any other audience. This works because the token is short-lived, the subjects are services rather than browsers full of extensions, and the audience check is clear.

Short-lived access tokens with server-controlled refresh tokens are a good fit. The API hot path gets the local-verification benefit. The session lifecycle still has state where state matters: refresh-token rotation, device records, logout, risk checks, and account lockout. You're not pretending JWTs solve revocation. You're using them to keep frequent API checks cheap while keeping long-term authority behind a revocable handle.

Federated identity is a good fit when you follow the profile. OpenID Connect ID tokens are JWTs. The OpenID Connect Core 1.0 Final specification, February 25, 2014, requires ID tokens to be signed using JWS and optionally signed then encrypted. The relying party validates issuer, audience, expiration, nonce, and signature using keys discovered through the provider. That works because the protocol defines who issues the token, who consumes it, where keys come from, and which claims matter.

SAML is different: SAML 2.0 is XML-based and was approved as an OASIS Standard in March 2005, as noted by OASIS. But in real identity stacks, SAML and OIDC often meet at gateways. A SAML assertion might be exchanged for an internal JWT, or an OIDC provider might sit beside older SAML integrations. The JWT is useful there when it's the internal, signed representation of a clearly validated external identity assertion, not a casual bag of profile data.

Signed callback tokens are a good fit. Email verification links, passwordless login links, unsubscribe links, invite links, and webhook callbacks often need a compact, tamper-evident blob with one purpose and a short lifetime. A JWT can carry sub, aud, exp, jti, and a purpose like email_verify. The receiver verifies the signature and purpose before acting. If the action must be one-time, store the jti after use. The token format helps with integrity; your state check handles replay.

The pattern in all of these: small claim set, explicit audience, short lifetime, pinned algorithm, known issuer, controlled keys, and a plan for replay or revocation when the action needs it.

The algorithm list and what to choose

The JOSE algorithm names are registered through JSON Web Algorithms. The main source is RFC 7518, JSON Web Algorithms, May 2015, with EdDSA added by RFC 8037, January 2017. The IANA JOSE registry tracks the registered names.

Here's the practical view.

HS256, HS384, HS512 are HMAC with SHA-256, SHA-384, and SHA-512. Use them only when the signer and every verifier are allowed to share the same secret. That's common for one backend service verifying its own tokens. It's a poor fit when third parties or many internal services need to verify but shouldn't be able to mint tokens. If you use HMAC, the secret must be high entropy. A human password or short .env string isn't enough.

RS256, RS384, RS512 are RSA PKCS #1 v1.5 signatures with SHA-256, SHA-384, and SHA-512. RS256 became the default in many identity-provider examples because RSA support is everywhere. That ubiquity is its best argument. The tradeoff is larger keys, larger signatures, older padding, and a long history of libraries and apps wiring RSA public keys into algorithm-confusion mistakes. Avoid RS256 for new systems unless compatibility pushes you there.

ES256, ES384, ES512 are ECDSA over P-256, P-384, and P-521 with matching SHA-2 hashes. ES256 gives small signatures and good modern support. The catch is implementation quality: ECDSA signing needs safe nonce behavior. RFC 8725 calls out deterministic ECDSA as a way to avoid nonce failures. If your platform and libraries handle ECDSA well, ES256 is a good choice for new systems.

PS256, PS384, PS512 are RSA-PSS signatures with SHA-256, SHA-384, and SHA-512. If you need RSA because your HSM, identity provider, or partner ecosystem is built around it, prefer PS256 over RS256 when all parties support it. RSA-PSS is the better RSA signature scheme. The constraint is adoption: older JWT consumers may support RS256 but not PS256.

EdDSA means EdDSA signatures, usually Ed25519 in JOSE deployments. RFC 8037 defines EdDSA for JOSE and notes deterministic signing for EdDSA operations. Ed25519 keys and signatures are small, signing and verification are fast, and the API is harder to misuse than raw ECDSA. For a new internal system where your libraries and identity stack support it, EdDSA is my first pick.

The opinionated version:

Use EdDSA when your stack supports it. Use ES256 when EdDSA doesn't fit but modern elliptic-curve support is solid. Use PS256 when you need RSA and consumers support PSS. Use RS256 when compatibility requires it, especially with older OIDC or enterprise integrations. Use HS256 only for tightly controlled symmetric trust boundaries. Don't use none for authentication.

Also: don't let the token choose from this menu at runtime. Your verifier's configuration chooses. The token header reports what was used, and your verifier checks that it matches policy.

Recent JWT CVEs worth learning from

As of May 2026, recent NVD records still show the same themes: algorithm confusion, signature checks skipped in edge paths, header parameters accepted too casually, and denial-of-service risks in claim validation. A few examples:

CVEAffected areaLesson
CVE-2023-48223fast-jwt before 3.3.2Public-key detection missed some PEM forms, allowing HS256-with-public-key algorithm confusion in certain RS256 setups.
CVE-2023-48238joaquimserafim/json-web-tokenVerification used the untrusted token header algorithm, enabling HS256 vs RS256 confusion.
CVE-2024-33531lua-resty-jwt 0.2.3A crafted enc header could bypass JWT parsing signature checks. Header handling is part of auth logic.
CVE-2026-29000pac4j-jwt before fixed 4.5.9, 5.7.9, and 6.3.3 linesEncrypted JWT processing could allow forged authentication via a JWE-wrapped PlainJWT when signature verification was bypassed.
CVE-2026-34950fast-jwt 6.1.0 and earlierA regex anchor issue re-enabled the same algorithm-confusion class patched in CVE-2023-48223. Fixes need regression tests.
CVE-2026-35041fast-jwt 5.0.0 to 6.2.0A regex-based allowedAud option could be driven into catastrophic backtracking by attacker-controlled aud. Claim validation can be a DoS surface.
CVE-2026-35042fast-jwt 6.1.0 and earlierUnknown critical header parameters were accepted instead of rejected, violating the crit requirement in JWS.
CVE-2026-39413LightRAG before 1.4.14An application accepted JWTs using alg: none, leading to unauthorized access. Decode paths don't belong in auth decisions.
CVE-2026-38651Netmaker before 1.5.0Host token verification failed to validate the JWT signature, allowing forged host tokens.

The point isn't "never use library X." Libraries patch, APIs change, and CVE entries age. The point is that JWT bugs cluster around a few boring edges: algorithm policy, key type, header trust, signature verification, audience validation, replay, and token lifetime. If your tests don't cover those edges, you're trusting the sharpest part of the system to vibes.

What our JWT decoder does and doesn't do

tooleras.com/tools/jwt-decoder is a decoder. That's useful, but it's a narrow kind of useful.

It can split a JWT into parts, base64url-decode the header and payload, and render the JSON so you can inspect claims while debugging. That's the right job for a browser-side utility: help you see what a token says.

It doesn't validate the signature. It doesn't know your issuer's keys. It doesn't know which algorithms your production API allows. It doesn't know whether kid maps to a trusted key. It doesn't know your expected aud. It doesn't enforce your iss. It doesn't decide whether a token is expired for your system's clock-skew policy. It doesn't know whether jti has already been used or revoked.

That gap is deliberate. Decoding is not verification. A decoded token is an observation, not an auth decision. Use the decoder to answer debugging questions like "what aud did we mint?", "is the timestamp seconds or milliseconds?", "why does this token have scope but not permissions?", or "did the gateway rewrite the issuer?"

For production auth decisions, use a maintained JWT library on the server, configured with explicit issuer, audience, algorithms, keys or JWKS, lifetime rules, and any app-specific checks. A browser decoder should never be the thing that tells your API who gets in.

A safer JWT checklist

Before shipping a JWT-based design, make these decisions in writing:

  • What is the token's single purpose?
  • Who issues it?
  • Who is allowed to consume it?
  • Which exact algorithm is allowed?
  • Which key or JWKS is trusted?
  • How long does it live?
  • What claims are required?
  • What claims are forbidden because they're private or too stale?
  • What happens on logout, password reset, user disable, tenant removal, and key compromise?
  • Where might the token be logged?
  • Is the token sent only over HTTPS?
  • Is it stored somewhere JavaScript can read?
  • Is there a replay risk, and if so, is jti checked server-side?

If that list feels heavier than the feature you're building, that's a signal. You may want an opaque session token.

FAQ

Is a JWT encrypted?

Usually, no. The common three-part JWT in an Authorization: Bearer header is usually signed JWS, not encrypted JWE. You can decode the header and payload. Encryption requires JWE, which has a different structure and operational cost.

Can I put passwords, API keys, or secrets in a JWT?

No. Don't put secrets in a signed JWT. Anyone who has the token can decode the payload. Treat signed JWT contents as visible to the bearer and to systems that process requests.

Can I revoke a JWT?

You can, but not for free. A signed JWT remains valid until expiration unless the verifier checks revocation state, a token version, a denylist, refresh-token state, or something similar. Short lifetimes reduce how much revocation has to do.

What's the difference between JWT and a session cookie?

A JWT is a token format containing claims. A session cookie is a browser storage and transport mechanism, usually holding an opaque session ID. You can put a JWT in a cookie, but that doesn't make it a server-side session. Opaque session IDs are easier to revoke. JWTs are easier to verify locally across services.

Should I use JWT for session management?

For many browser apps, no. If you need logout, device lists, account lockout, and immediate revocation, a server-side session is simpler. JWTs make more sense as short-lived access tokens paired with server-controlled refresh/session state.

Is alg: none still a risk?

Yes, mostly through bad library use or application code that decodes without verifying. Good libraries won't accept none unless explicitly configured, but mistakes still happen. Reject it in production auth paths.

Is RS256 safe?

RS256 can be safe when implemented and configured correctly. It's widely supported, which is why many identity providers still use it. For new systems, prefer EdDSA, ES256, or PS256 when your ecosystem supports them. If you use RS256, pin algorithms and never let the token switch the verifier into HMAC mode.

What should go in a JWT payload?

Only claims the verifier needs and that you're willing to expose: issuer, subject, audience, expiration, scopes, tenant ID, token ID, and purpose. Avoid PII, secrets, large permission objects, internal risk labels, and data that must update immediately.

Where should I store JWTs in a browser?

There's no perfect answer. Local storage is easy for JavaScript and easy for XSS to steal. HttpOnly cookies hide the token from JavaScript but bring CSRF considerations, mitigated with SameSite and CSRF defenses. For many apps, an HttpOnly Secure SameSite cookie with a server-side session or refresh handle is the safer default.

Does checking exp mean the token is valid?

No. exp is one check. You also need signature verification, accepted algorithm, issuer, audience, not-before time, key trust, and app-specific claims. An unverified payload's exp is just attacker-controlled JSON.

Are JWTs bad?

No. They're overused. JWTs are good at carrying signed claims across trust boundaries for a short time. They're bad as secret containers, long-lived browser sessions, and substitutes for revocation state.

Can a JWT be both signed and encrypted?

Yes. A nested JWT can be signed and then encrypted. OpenID Connect Core describes that order for encrypted ID tokens. Use it when you need both integrity and confidentiality, and make sure your libraries and key management are ready for the extra moving parts.

Sources

Use JWTs when the format's strengths match the job: compact claims, signed by a known issuer, verified by a known audience, valid for a short time. Keep them small. Keep them boring. Keep private data somewhere the bearer token can't casually carry it into every log line you own. A JWT is a signed statement, not a vault, not a session database, and not a permission slip to skip thinking about auth.

jwtjson web tokenjwsjweauthenticationoauthalgorithm confusionpasskeys
Advertisement

Related articles

All articles

Practice with free tools

200+ free developer tools that run in your browser.

Browse all tools →