The most common JWT misconception I hear is: this token is JWT-encrypted (a phrase I hear all the time).

It is not encrypted. The JWT header and payload are Base64URL-encoded, the same family as Base64. Anyone with the string can paste it into any online JWT decoder and read the user id, expiration time, and role list right away. This article exists so that next time you read the phrase JWT-encrypted, you can immediately say — this is signed, not encrypted.

30-second overview

  • A JWT (JSON Web Token) usually has three parts: header.payload.signature, separated by dots.
  • The header and payload are Base64URL-encoded (RFC 4648). They are reversible and fully readable.
  • The signature is a digital signature the server produces over the first two parts using a secret or private key. It proves the token was not tampered with. It does not prove the content is confidential.
  • Never put passwords, API keys, or other secrets in the payload. To protect a secret, use real encryption (AES, libsodium, age, GPG).

What a real JWT looks like

The JWT Decoder tool has a Load sample button that produces a standard three-part JWT. The tool shows each of the three parts decoded on the right, plus a security note: signature not verified. That note is the default state, because decoding alone never verifies the signature.

Concretely, the decoded header contains the alg and typ fields. alg is the signing algorithm, typ is usually JWT. The decoded payload contains claims such as iss, sub, aud, iat, exp, jti, and role, which represent the issuer, subject, audience, issued-at, expiration, token id, and role respectively.

5 real scenarios where JWT is wrong

Scenario 1: Putting a user password in the payload

Payload example: a field like password: Hunter2 inside the JWT payload.

Why people fall for it: they assume a server-signed token is automatically safe. In practice, anyone who gets the token (browser extension, client log, Referer header, CDN cache) can paste it into jwt.io or our JWT decoder and read the password field in one second. The signature prevents tampering, not snooping.

Fix: passwords never go in a JWT. Sensitive fields belong in a separate encrypted channel (server-side encryption, a vault, etc.) and only non-sensitive ids and roles go into claims.

Scenario 2: Putting an API key or database connection string in the payload

Payload example: a field like api_key: … inside the claims.

Putting an API key in the payload is the same class of mistake as putting a password there. An attacker who steals any user’s JWT can read the backend API key directly from the payload, then call any internal endpoint without going through authentication.

Fix: API keys, connection strings, and tokens are secrets and must live on the server. If the client needs to reference one, go through a dedicated auth endpoint that issues a short-lived bearer token.

Scenario 3: Putting user privacy fields in the payload

Payload example: fields like email, phone, and ssn_last4 stuffed into the claims.

JWT exists to pass claims across services, and the in-between path (gateways, logs, CDNs) often sees the full token. Writing private fields into the payload is equivalent to broadcasting those fields across your systems and violates GDPR and similar privacy laws.

Fix: keep JWT to the minimum identity needed: sub, role, exp, iss, aud. Let the receiver look up private fields from its own database using sub.

Scenario 4: Setting alg to none or not validating the algorithm

Header example: alg: none, or an empty signature.

This is the textbook attack. The attacker changes alg to none, clears the signature, and forges a payload claiming admin. If the server does not enforce the algorithm strictly, the request sails through. Our JWT decoder shows a red warning whenever alg is none or the signature is empty: production systems should usually reject it.

Fix: the server must use a strict allowlist of algorithms (for example, only RS256 or HS256) and compare the alg field against that list. Never pick a verification algorithm dynamically based on the header. That is a classic CVE pattern.

Scenario 5: Assuming exp is still valid because the token decodes

Payload example: a field like exp: 1700000000 (seconds) that the library treats as milliseconds, or a server clock that has drifted.

If the server clock is out of sync, the client clock has drifted, or the iat/exp unit is wrong (milliseconds vs seconds), you can see the opposite of what you expect: a token that has not yet reached exp gets rejected, or one whose exp has long passed gets accepted.

Fix: the server always uses its own time, never trusts client headers. If the tool shows iat far in the future, or exp looks like a 13-digit millisecond value instead of a 10-digit seconds value, it raises a millisecondTimestamp warning. That is why running a token through the JWT decoder before deep auth debugging saves you hours.

4 counterintuitive facts

Fact 1: A JWT string is 30-50% longer than the raw JSON

Base64URL encoding inflates the size. Three bytes become four characters, and the longer the payload the bigger the overhead. For short claims it is invisible, but a few KB of payload shows the difference.

Fact 2: The signature does not tell you the content is genuine

The signature proves one thing: the server signed it with its secret, so changing a single byte breaks the signature. It does not prove who the signer is, whether the signer is trusted, or whether the signing key has been leaked.

Verification is the server’s job, not the client’s. After a client receives a token, all it can do is send it back to the server for verification. That is exactly why our tool explicitly says the signature is not verified.

Fact 3: Different language libraries behave differently by default

  • jsonwebtoken (Node.js): accepts many algorithms by default. In production you must pass an explicit allowlist such as algorithms: [RS256].
  • PyJWT (Python): strict by default. It only validates when you pass an explicit algorithms= argument.
  • java-jwt (Java): permissive by default. You must specify algorithms manually.

When you cross language boundaries, a quick look at the default behavior of the other library avoids most CVEs.

Fact 4: Expired JWT = rejected by server, not by the client

The exp claim is for the server. Even if the client sees a token whose exp is still in the future, the server may still reject it. The server may enforce a shorter lifetime, force a refresh, or actively invalidate the token based on other policies (IP change, role change, manual revoke).

That is why a valid-looking status in the JWT decoder does not guarantee the server will accept the token.

  1. Keep the payload to the minimum: sub, iss, aud, exp, iat, jti, role. No passwords, API keys, private data, or connection strings.
  2. Before verifying, hard-code an algorithm allowlist (for example algorithms: [RS256]) and never pick from the header dynamically.
  3. Have the server generate exp itself, never trust the client’s exp field. A client that mutates the payload and re-signs cannot succeed (the signature will not match), but do not rely on that.
  4. When debugging auth issues, use the JWT decoder offline to read claims. Never paste a real token into a site that uploads. Our tool runs fully locally: nothing leaves the browser and no logs are kept.
  5. To protect a secret, use real encryption. JWT does not solve confidentiality, only authentication and tamper-resistance. For secrets use AES-GCM, libsodium, or age.
  6. JWT is not a session. It is stateless, but that does not mean it is revocable. To invalidate tokens, the server must maintain a denylist, or use short lifetimes plus refresh tokens.

JWT is signature plus encoding, not encryption. When you need confidentiality, use encryption. When you need authentication, JWT is the right tool.

Try our JWT decoder — click Load sample to generate a standard JWT, then Decode to see the header, payload, and signature segments, plus the red signature-not-verified note. To compare Base64 with real encryption, read our Base64 Is Not Encryption article and the Text Encoder tool.