DEV Community

Cover image for JWT Is Not Secure — Until You Understand JWS and JWE
Nimesh Thakur
Nimesh Thakur

Posted on

JWT Is Not Secure — Until You Understand JWS and JWE

Start Here

JWTs are not secure by default.

They're fast. Stateless. Powerful. But easy to break if you don't understand what you're actually doing.

Most developers copy-paste jwt.verify(token, secret) and move on. That's where the problems start.

The JOSE Family

JWT stands for JSON Web Token. It's part of JOSE: JSON Object Signing and Encryption.

Two things matter:

JWS (JSON Web Signature) — Signs data. Anyone can read it, but tampering is detectable.

JWE (JSON Web Encryption) — Encrypts data. Only the recipient can read it.

Most JWTs are JWS because you rarely need to hide the payload. You just need proof it hasn't been modified.

What a JWT Actually Looks Like

eyJhbGci...  .  eyJzdWIi...  .  2Xh3n...
   header         payload      signature
Enter fullscreen mode Exit fullscreen mode

Each part is base64url-encoded JSON:

HEADER              PAYLOAD              SIGNATURE
{ "alg": "HS256" }  { "sub": "user_123" }  HMAC(header + payload)
Enter fullscreen mode Exit fullscreen mode

Visual breakdown:

┌─────────────┐
│   HEADER    │ → Algorithm + metadata
├─────────────┤
│   PAYLOAD   │ → Your claims (readable!)
├─────────────┤
│  SIGNATURE  │ → Proof of integrity
└─────────────┘
Enter fullscreen mode Exit fullscreen mode

The payload is NOT encrypted. Base64 is encoding, not encryption. Anyone can decode and read it.

atob("eyJzdWIiOiJ1c2VyXzEyMyJ9")
// {"sub":"user_123"}
Enter fullscreen mode Exit fullscreen mode

The signature is what protects you. It proves the token came from someone with the key and hasn't been modified.

The Header

{
  "alg": "HS256",
  "kid": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
Enter fullscreen mode Exit fullscreen mode

alg — How it's signed (HS256, RS256, ES256)

kid — Which key was used (UUID only, never a path or URL)

jku — URL to fetch keys (never trust this from the token)

Rule: Don't make security decisions based on header values. Attackers control headers too.

The Payload

{
  "sub": "user_5839",
  "exp": 1640995200,
  "iss": "https://auth.yourapp.com",
  "aud": "https://api.yourapp.com"
}
Enter fullscreen mode Exit fullscreen mode

exp — Expiration (mandatory)

iss — Who issued it

aud — Who it's for

jti — Unique ID (for logout/blacklisting)

Don't put secrets here. Passwords, API keys, SSNs—none of that belongs in a JWT.

When JWE Makes Sense

JWE encrypts the payload:

header.encrypted_key.iv.ciphertext.tag
Enter fullscreen mode Exit fullscreen mode

Use it when:

  • Tokens pass through untrusted intermediaries
  • Compliance requires encryption at rest

Skip it when:

  • You're using HTTPS (already encrypted in transit)
  • You control both issuer and verifier

Most systems don't need JWE.

Where Security Breaks

Algorithm Confusion

Token says "alg": "HS256" but your server expects RS256. If your code doesn't enforce the algorithm, attackers can forge tokens.

// Vulnerable
const decoded = jwt.verify(token, publicKey);
Enter fullscreen mode Exit fullscreen mode

The "none" Algorithm

Token with "alg": "none" and no signature. Some libraries accept this.

eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.
Enter fullscreen mode Exit fullscreen mode

Trusting Headers

Using kid from the token to load keys:

{ "kid": "../../etc/passwd" }
{ "kid": "http://attacker.com/keys" }
Enter fullscreen mode Exit fullscreen mode

If you do loadKey(header.kid) without validation, you're in trouble.

Skipping Claim Validation

Verifying the signature isn't enough. You must validate:

  • Algorithm matches what you expect
  • Token hasn't expired
  • Issuer is correct
  • Audience is correct

Fetching Keys from Untrusted URLs

Token's jku points to https://evil.com/keys. Your server fetches keys from there, "verifies" the attacker's token.

How to Verify Correctly

jwt.verify(token, key, {
  algorithms: ['RS256'],              // Hardcode this
  issuer: 'https://auth.yourapp.com', // Hardcode this
  audience: 'https://api.yourapp.com', // Hardcode this
  maxAge: '30m'
});
Enter fullscreen mode Exit fullscreen mode

Rules:

  1. Explicitly set allowed algorithms
  2. Validate issuer and audience
  3. Never trust jku from the token—configure JWKS URL server-side
  4. Use UUIDs for kid, not paths or URLs
  5. Keep access tokens short (15-30 min)
  6. Use longer refresh tokens with revocation support

The Mental Model

A JWT is a signed statement.

The signature proves integrity and authenticity. It doesn't prove the token should be accepted.

You decide that by validating the claims.

Final Thought

Tools don't fail. Understanding fails.

Most JWT vulnerabilities come from trusting input you shouldn't trust or skipping validation you should enforce.

Configure strictly. Validate everything. Never assume the library handles security for you.

The difference between safe and broken is understanding what the signature actually proves.


Reference: RFC 7515 (JWS), RFC 7516 (JWE), RFC 7519 (JWT)

Top comments (0)