JWT Is Not Encryption — Common Developer Misconceptions
A few years ago, a teammate showed me a JWT he'd pulled from a production access log:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOjQ1MTIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc0MDAwMDAwMH0
.
dGhpcyBpc250IGEgcmVhbCBzaWduYXR1cmU
"Good thing this is encrypted," he said. "Nobody can see what's inside."
I copied the middle segment, ran it through atob(), and showed him:
{
"userId": 4512,
"role": "admin",
"exp": 1740000000
}
His face went pale. He'd been storing internal user IDs and permission levels in JWT payloads for months, assuming they were cryptographically hidden from end users.
They weren't. They never have been.
JWT payloads are Base64URL encoded, not encrypted. Anyone holding the token can read everything inside it. The encoding takes about three seconds to reverse in a browser console. No secret key. No decryption library. Just atob().
This misconception is everywhere. I've seen it in fintech apps, healthcare dashboards, internal admin panels, and at least two authentication libraries that should have known better. Each time, developers assumed the garbled-looking token was protecting data that was actually sitting in plain sight.
This article walks through what JWT actually is, why it looks encrypted when it's not, what encryption actually means, and how to avoid the security mistakes that come from this confusion.
Why JWT Looks Encrypted (And Why It Tricks Developers)
Here's a JWT in its full form:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Stare at that for a moment. It looks random. It looks like ciphertext. It has the visual signature of something cryptographic -- no readable words, no discernible structure beyond the two dots, just a blob of seemingly meaningless characters.
Your brain does what brains do with unfamiliar-looking strings: it categorizes this as "encrypted data." This is an entirely reasonable heuristic in most contexts. But it's wrong here.
Three things reinforce the illusion:
-
The output is visually opaque. You cannot skim a JWT and understand its contents the way you could skim a JSON object or an XML document.
-
Authentication libraries hide the internals. Most JWT libraries give you
jwt.sign()andjwt.verify()and return plain JavaScript objects. You never see the encoded string unless you go looking for it. -
Tutorials rarely explain the encoding layer. They jump straight to "JWTs are signed tokens for authentication" without pausing to explain that the payload is just Base64URL-encoded JSON that anyone can read.
The combination is potent. Developers who've worked with JWTs for years sometimes still believe the payload is hidden.
The Base64 is Not Encryption guide covers this visual illusion in more detail -- it's a pattern that shows up across multiple technologies, not just JWTs.
What JWT Actually Is
JWT stands for JSON Web Token. At its core, a JWT is three pieces of data, Base64URL encoded, joined with dots:
header.payload.signature
Each segment serves a specific purpose, and none of them involve encryption by default.
| Component | What It Contains | Protected How? |
|---|---|---|
| Header | Algorithm and token type (JSON) | Not protected -- readable by anyone |
| Payload | Claims: user ID, role, expiration, etc. (JSON) | Not protected -- readable by anyone |
| Signature | Cryptographic hash of header + payload | Tamper-proof if verified correctly |
The distinction matters enormously: two-thirds of every JWT is completely readable by anyone who has the token. The third part -- the signature -- prevents tampering. It does not prevent reading.
Most JWTs in production are JWS (JSON Web Signature) tokens: signed, not encrypted. The signature proves the token came from your server and hasn't been modified. It says nothing about whether the payload should be hidden.
JWT Structure in Detail
Header
The header is minimal JSON that tells the receiver which algorithm signed the token:
{
"alg": "HS256",
"typ": "JWT"
}
HS256 means HMAC with SHA-256 -- symmetric signing using a shared secret. RS256 (RSA with SHA-256) is also common, using public/private key pairs. The header is Base64URL encoded and sits at the front of the token.
Encoded:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload
The payload holds the claims -- the actual data the token carries:
{
"sub": "user-4512",
"role": "admin",
"iat": 1716134400,
"exp": 1716220800
}
Common standard claims include sub (subject), iat (issued at), exp (expiration), and iss (issuer). You can also add custom claims like role or permissions.
This entire JSON object gets Base64URL encoded:
eyJzdWIiOiJ1c2VyLTQ1MTIiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTYxMzQ0MDAsImV4cCI6MTcxNjIyMDgwMH0
Anyone can decode this back to the original JSON. There is no cryptographic barrier between an attacker and your payload contents.
Signature
The signature is where security actually lives. It's computed by hashing the encoded header and payload together with a secret key:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
If an attacker changes "role": "user" to "role": "admin" and re-encodes the payload, the signature will no longer match. A properly implemented server catches this mismatch and rejects the token.
The signature guarantees integrity and authenticity. It does not guarantee confidentiality.
Base64URL Is Not Encryption
JWT uses Base64URL encoding -- a URL-safe variant of Base64. Its job is purely mechanical: converting arbitrary binary or text data into a character set that survives transport through HTTP headers, URLs, cookies, and proxy servers without corruption.
Standard Base64 uses +, /, and = for padding. These characters cause trouble in URLs and query strings. Base64URL replaces + with -, / with _, and typically strips the trailing = padding.
| Standard Base64 | Base64URL |
|---|---|
+ | - |
/ | _ |
= (padding) | Omitted or stripped |
None of this has anything to do with security. It's character set conversion, the same category of operation as URL encoding or hex encoding. Replacing + with - does not make data secret. It just makes it safe to put in a URL.
For a deeper walkthrough of why JWT chose Base64URL and how it affects debugging, see the guide on How JWT uses Base64URL encoding.
Real Example: Decoding a JWT Payload
Take this real JWT payload segment:
eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIn0
Decoding it in JavaScript takes one line:
const payload = JSON.parse(atob("eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIn0"));
console.log(payload);
// → { userId: 123, role: "admin" }
In Python:
import base64, json
payload = "eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIn0"
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded = json.loads(base64.urlsafe_b64decode(payload))
print(decoded)
# → {'userId': 123, 'role': 'admin'}
In the browser, you don't even need code. Paste the token into the JWT Decoder and the payload appears immediately.
No secret key was involved. No decryption was performed. The data was never hidden -- it was just formatted for transport.
What Actually Protects JWT Security
The signature is what protects JWT security, and it protects exactly one thing: tamper resistance.
Here's what a signature gives you:
- Integrity: If anyone changes a single character in the header or payload, the signature will not match. The server detects the modification and rejects the token.
- Authenticity: With a shared secret (HS256) or a trusted public key (RS256), the server can confirm the token was issued by someone who holds the signing key.
Here's what a signature does NOT give you:
- Confidentiality: The payload is still Base64URL encoded. It is still readable by anyone who has the token.
- Encryption: There is no cipher involved. No AES, no RSA encryption of the payload. Just hashing.
Think of the signature like a tamper-evident seal on a glass jar. The seal tells you nobody opened the jar after it left the factory. It does not make the jar's contents invisible.
Common Misconception #1: "Users Cannot Read JWT Payloads"
This is the one I encounter most often, and it leads directly to the worst security mistakes.
A developer adds a JWT claim like this:
{
"email": "user@example.com",
"internalAccountId": "acc-88291",
"apiKey": "sk-live-9a8b7c6d5e4f3a2b1c0"
}
The reasoning goes: "It's in a JWT, so the user can't see it." This is completely wrong. Any user who receives this token -- in a cookie, in localStorage, in an Authorization header -- can decode the payload in seconds and read every field.
The correct mental model: JWT payloads are public. Anyone with the token can read them. Design your claims accordingly.
If you need to associate an internal ID with a session but don't want to expose it, store it server-side in a session store and put an opaque session reference in the JWT instead.
Common Misconception #2: "JWT Looks Random, So It's Safe"
This is the visual illusion problem. JWTs survive in HTTP headers. They look like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That's a blob of text with no recognizable words. It looks like ciphertext. But a cipher is an algorithmic transformation that requires a key to reverse. Base64URL is a character encoding with a publicly documented mapping that reverses deterministically.
The difference between "looks random" and "is encrypted" is the difference between a diary written in pig Latin and a diary locked in a safe. Pig Latin looks confusing at a glance, but anyone who knows the rules can read it in seconds. A safe requires a key or combination.
Base64URL is pig Latin for computers.
Common Misconception #3: "Decoding a JWT Requires the Secret Key"
This confusion is the root cause of many authentication bugs. Developers conflate two completely different operations:
- Decoding a JWT means Base64URL-decoding the header and payload to read the JSON contents. This requires nothing -- no key, no secret, no password. It's just parsing.
- Verifying a JWT means checking the cryptographic signature to confirm the token is authentic and hasn't been tampered with. This requires the secret key (HS256) or the public key (RS256).
When someone says "I decoded the token and it says the user is an admin, so I granted access," they've made a catastrophic error. They performed the operation anyone can do and treated it as if it were the operation only the server can do.
The distinction between decoding and verifying is so fundamental to JWT security that it deserves its own deep dive: JWT Decode vs Verify -- the difference developers keep missing.
Decode vs Verify -- Side by Side
Let's make this concrete with code.
Decode (read-only, no security)
// Node.js with jsonwebtoken
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(token);
// Returns: { userId: 123, role: "admin", iat: 1716134400 }
// NOTE: no secret was provided. No verification happened.
// Browser, no library needed
const payload = JSON.parse(atob(token.split('.')[1]));
// Same result. Anybody can do this.
Verify (cryptographic validation)
const jwt = require('jsonwebtoken');
try {
const verified = jwt.verify(token, SECRET_KEY);
// Only reaches here if signature is valid and token is not expired
console.log(verified);
} catch (err) {
// Signature invalid, token expired, or token malformed
console.log('Token rejected:', err.message);
}
The output of both calls is a JavaScript object. That's why developers confuse them -- both return something that looks authoritative. But decode() returns attacker-controlled data. verify() returns data the server trusts.
A good rule of thumb: if the word decode appears in your authentication middleware, stop and audit it immediately.
Encoding vs Encryption -- The Distinction That Matters
The encoding-vs-encryption confusion is not specific to JWT. It appears whenever developers encounter data transforms they didn't write themselves. But the distinction is straightforward:
Encoding
- Purpose: Format data for transport or storage compatibility
- Examples: Base64, URL encoding, hex encoding, UTF-8
- Requires a key? No
- Reversible by anyone? Yes, instantly
- Provides secrecy? No
Encoding is a mechanical transform. It takes data in one representation and converts it to another representation that's safe for a particular medium. Think of it like translating a sentence into Morse code -- anyone who knows Morse code can translate it back.
Encryption
- Purpose: Protect data confidentiality
- Examples: AES-256-GCM, RSA-OAEP, ChaCha20-Poly1305
- Requires a key? Yes
- Reversible by anyone? No -- only with the key
- Provides secrecy? Yes
Encryption is a cryptographic transform. It mathematically scrambles data so that recovering the original requires a key that an attacker does not possess. Without the key, the encrypted output should be indistinguishable from random noise.
JWTs use encoding (Base64URL) for the header and payload, and signing (HMAC/RSA/ECDSA) for the signature. Encryption is not involved unless you explicitly use JWE.
The Real Security Mistake: Trusting Decoded Payloads
Here's the mistake that creates actual vulnerabilities, not just misunderstandings.
A developer writes authentication middleware:
// BAD: decode-only middleware
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.sendStatus(401);
const token = authHeader.split(' ')[1];
const user = jwt.decode(token); // <-- decode, not verify
req.user = user;
next();
});
app.get('/admin', (req, res) => {
if (req.user.role === 'admin') {
// Attacker reaches here by forging their own payload
res.json({ secretData: '...' });
}
});
This code works perfectly in development. The server starts, the frontend sends a real token, jwt.decode() returns the payload, and the admin check passes. Everything looks fine.
But an attacker can construct a token with any payload they want:
// Attacker creates a forged token in their browser console
const forgedHeader = btoa(JSON.stringify({ alg: "none", typ: "JWT" }));
const forgedPayload = btoa(JSON.stringify({ role: "admin", userId: 9999 }));
const forgedToken = forgedHeader + "." + forgedPayload + ".";
// Send this to the server
The server calls jwt.decode(), which happily parses the payload and returns { role: "admin", userId: 9999 }. The middleware trusts it. The attacker gets admin access.
The fix is one function call away:
// GOOD: verified middleware
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.sendStatus(401);
const token = authHeader.split(' ')[1];
try {
req.user = jwt.verify(token, SECRET_KEY); // <-- verify, not decode
next();
} catch (err) {
return res.sendStatus(403);
}
});
Now the attacker's forged token fails signature verification and gets rejected with a 403. The "alg": "none" trick also fails because jwt.verify() explicitly rejects the none algorithm.
I've seen the decode-instead-of-verify bug in production code at startups, in open-source projects, and in enterprise internal tools. It's one of those bugs that's trivially easy to write and trivially easy to exploit.
Forged JWT Example: How Easy It Is
To drive the point home, here's exactly how an attacker escalates from "user" to "admin" against a decode-only endpoint.
Step 1: The attacker receives a legitimate JWT from the application:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoidXNlciJ9.abc123signature
Step 2: They decode the payload to see what they're working with:
atob("eyJ1c2VySWQiOjQyLCJyb2xlIjoidXNlciJ9");
// → '{"userId":42,"role":"user"}'
Step 3: They modify the payload:
{
"userId": 42,
"role": "admin"
}
Step 4: They re-encode and assemble a new token:
const newPayload = btoa(JSON.stringify({ userId: 42, role: "admin" }));
// "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ==" (Base64 with padding)
// After converting to Base64URL and removing padding:
// "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ"
Step 5: They construct a complete token with the original header, modified payload, and any signature (or none):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ.fakesignature
Step 6: They send this token to the server. If the server only decodes, the attacker is now an admin.
This is not theoretical. This exact attack works against any application that calls jwt.decode() and trusts the result.
Why This Confusion Happens So Often
The JWT misconception is not a failure of individual developers. It's a failure of how the technology is taught and packaged.
Documentation skips the encoding layer. Most JWT introductions say "JWTs are digitally signed tokens for authentication" and immediately show code examples with jwt.sign() and jwt.verify(). The Base64URL encoding -- which is the entire reason the token looks secret when it's not -- gets a one-sentence mention or none at all.
Library APIs create false equivalence. jwt.verify() returns a payload object. jwt.decode() also returns a payload object. A developer who doesn't read the docs carefully sees two methods that produce the same output and picks the one that requires fewer arguments.
Visual intuition is powerful. When something looks like random bytes, the brain classifies it as random bytes. Overriding that intuition requires understanding what Base64URL actually does, and most developers never have a reason to learn that until something goes wrong.
Local development hides the consequences. A decode-only middleware works perfectly on localhost with real tokens from the auth server. The exploit only becomes apparent when someone deliberately crafts a malicious token. Most developers never test that scenario.
When Encryption Actually IS Needed: JWE
So far I've been saying JWT is not encrypted, and for the vast majority of tokens in production, that's true. But the JWT family of standards does include an encrypted variant.
JWE (JSON Web Encryption) encrypts the payload using standard cryptographic algorithms like RSA-OAEP for key encryption and AES-GCM for content encryption. A JWE token has five segments instead of three:
header.encryptedKey.initializationVector.ciphertext.authenticationTag
With JWE, the payload genuinely is hidden from anyone without the decryption key. The token structure is fundamentally different from signed JWTs.
However, JWE is rarely used in practice. Most authentication systems don't need payload encryption -- they need tamper protection and authenticity, which signatures provide. JWE adds complexity (key management, algorithm negotiation, performance overhead) that most teams don't want to deal with.
The practical advice is simple: if you need confidentiality for certain data, don't put that data in a JWT at all. Store it server-side and put an opaque reference in the token. If you absolutely must transport encrypted claims, look into JWE libraries for your platform. But know that you are solving a problem most architectures avoid by design.
Safe JWT Payload Practices
Given that JWT payloads are public, here's what should and should not go into them.
Safe to include:
- User ID or subject identifier
- Roles and permissions (these are typically checked server-side against a verified token)
- Expiration timestamp (
exp) - Issued-at timestamp (
iat) - Token issuer (
iss) - Non-sensitive metadata (token version, client ID)
Never include:
- Passwords or password hashes
- API keys or access tokens for other services
- Credit card numbers or financial data
- Personal identification numbers (SSN, tax ID, etc.)
- Internal database IDs that would be useful to an attacker
- Session secrets or refresh tokens
- Any data subject to GDPR, HIPAA, or PCI compliance that must not be exposed
The rule is simple: assume every JWT payload will be read by the end user, because it can be. If you wouldn't put the data in a client-visible cookie or a URL query parameter, don't put it in a JWT claim.
JWT and Frontend Storage
Many single-page applications store JWTs in localStorage or sessionStorage:
// Common SPA pattern
localStorage.setItem('auth_token', jwt);
This works, but it introduces risk beyond the payload visibility issue. If your application has an XSS vulnerability -- and most complex SPAs eventually do -- an attacker's injected script can read localStorage and exfiltrate tokens.
Since JWT payloads are readable by anyone with the token, an XSS attack that steals a JWT immediately exposes every claim inside it. The attacker doesn't just get a session token; they get the user's ID, role, permissions, and anything else you put in the payload.
HttpOnly cookies mitigate the XSS exfiltration risk (JavaScript can't read HttpOnly cookies), but they don't change the fundamental reality that the payload is readable. An attacker who obtains the token through any means -- cookie or localStorage, network sniffing on a compromised network, server access logs, forwarded headers in a misconfigured proxy -- can decode it instantly.
Why JWT Still Works Well
None of this means JWT is broken or that you should avoid it. JWT is an excellent solution for what it was designed to do: carry verifiable claims between parties in a compact, URL-safe format.
The strengths are real:
- Stateless authentication. The server doesn't need to look up session data on every request. The token carries everything the server needs to know, protected by the signature.
- Horizontal scalability. Any server with the signing key (or public key) can verify tokens. No shared session store required.
- Cross-service portability. A token issued by an auth service can be verified by any other service in the architecture without calling back to the issuer.
- Standardized format. Libraries exist for every major language and framework. The RFC is well-defined and widely implemented.
The problem is not JWT itself. The problem is using JWT while believing it provides something it doesn't: encryption.
Understand what the signature protects (integrity, authenticity) and what it doesn't (confidentiality), and you'll use JWT correctly. Skip that distinction, and you'll eventually ship a vulnerability.
FAQ
Is JWT encrypted?
No, not by default. Standard JWTs are signed, not encrypted. The header and payload are Base64URL encoded, which is a reversible character encoding. Anyone with the token can decode and read the contents.
Can anyone read JWT payloads?
Yes. Paste any JWT into atob() in a browser console, a Base64URL decoder, or the JWT Decoder tool and the payload JSON appears immediately. No keys, passwords, or secrets required.
What protects JWT security?
The cryptographic signature. It guarantees two things: the payload hasn't been modified since the token was issued (integrity), and the token was created by someone who holds the signing key (authenticity). The signature does not hide the payload.
What is the difference between encoding and encryption?
Encoding converts data to a different format for transport or storage compatibility. It's reversible by anyone and requires no key. Examples: Base64, URL encoding, hex. Encryption scrambles data mathematically to prevent unauthorized reading. It requires a key to reverse and provides actual confidentiality. JWTs use encoding, not encryption, for the payload.
Can attackers modify JWT payloads?
Technically yes -- they can decode the payload, change the JSON, re-encode it, and send it. But if the server properly verifies the signature, the modified token will be rejected because the signature won't match the altered payload. The attack only works against applications that decode without verifying.
Should sensitive data be stored in JWT payloads?
Never. Assume every JWT payload is public. If the data would be problematic if an end user read it, do not put it in a JWT claim. Store sensitive data server-side and reference it with an opaque identifier in the token.
Does jwt.decode() require the secret key?
No. jwt.decode() only Base64URL-decodes the token and parses the JSON. It performs zero cryptographic validation and zero security checks. Anyone can call it on any JWT and read the payload.
What is JWE?
JWE (JSON Web Encryption) is a JWT variant that actually encrypts the payload. It uses standard encryption algorithms like AES-GCM and RSA-OAEP. JWE tokens have five segments instead of three and genuinely hide payload contents from anyone without the decryption key. However, JWE is rarely used in practice -- most systems prefer to keep sensitive data out of tokens entirely rather than adding encryption complexity.
Do I need JWE for my application?
Probably not. If you're asking the question, the answer is nearly always to keep sensitive data server-side and put a non-sensitive reference in the JWT. JWE adds meaningful complexity to key management, algorithm selection, and library support. Reserve it for cases where you have a specific compliance requirement to encrypt claims in transit between services.
Final Thoughts
JWT is not encryption. It's a signed, encoded data format for carrying verifiable claims.
The encoding (Base64URL) makes the token safe for transport through URLs and HTTP headers. The signing (HMAC, RSA, ECDSA) makes the token tamper-proof. Neither operation is encryption, and neither one hides the payload.
If there's one thing to take away from this article, it's this: JWT payloads are readable by anyone who has the token. Design your claims accordingly.
Once you internalize that, the rest of JWT makes sense. You understand why jwt.decode() and jwt.verify() are different. You understand why the secret key matters for verification but not for decoding. You understand why the signature protects integrity, not confidentiality. And you stop putting passwords, API keys, and internal IDs in JWT payloads.
If you work with JWTs regularly -- debugging OAuth flows, inspecting bearer tokens, troubleshooting authentication failures -- having a reliable JWT decoder nearby saves a surprising amount of time. The JWT Decoder decodes header and payload automatically, checks expiration, and formats claims for quick inspection during development and debugging.