JWT (JSON Web Token / jwt decoder / jwt online / decode jwt) is not encryption, it’s encoding — payload uses base64url which decodes to plaintext in one second, so never put passwords / PII / bank card / phone numbers in JWT payload. This article covers 8 real JWT security foot-guns + 5 forgery attack scenarios + 7-day protection plan, which together avoid at least 90% of vulnerabilities.

30-Second Overview

  • JWT is not encryption, it’s encoding — many developers think JWT is “secure” because base64url encoding looks garbled, but anyone can decode it in one second to get plaintext payload. JWT payload = plaintext, only signed, not encrypted
  • JWT standard 4 major risks: alg=none attack / HS256 vs RS256 public key confusion / exp not validated so expired tokens pass / signature stripping some libraries fall back to accept
  • JWT deployment 4 foot-guns: JWT secret hardcoded in Git scanned within hours / kid injection any file used as key / no blacklist old tokens still valid after logout / long expiration (7 days) password change doesn’t invalidate tokens
  • 5 real attack scenarios: phone number plaintext leak / alg=none forgery / HS256 public key signing / signature stripping passes validation / old tokens still valid after password change
  • 7-day protection plan (each ≤ 30 minutes): force algorithm whitelist (always verify exp) / JWT secret via env vars + .gitignore / kid strict format validation (whitelist chars) / signature must verify (none rejected) / short expiration (15 minutes) + refresh token rotation / blacklist + DB stores revoke state / never put PII in JWT payload
  • Use Piick JWT Decoder locally to decode suspicious tokens and check header / payload / exp / signature — 100% local with no upload, doesn’t pollute your audit log during debug

8 Real JWT Foot-Guns

Foot-Gun 1: JWT Is Not Encryption, It’s Encoding

Symptom: You put user phone number / email / bank card last 4 digits / ID hash in JWT payload, thinking “the token looks like garbled text, others can’t read it”. Until one day payload leaks, base64url decodes — plaintext phone + email + ID hash all exposed, GDPR compliance fine starts at ¥500k.

Why: JWT has three segments header.payload.signature, only signature is signed (HMAC/RSA signed to prevent tampering), but payload itself is plaintext encoded with base64url. Anyone with the token (including frontend logs / CDN / reverse proxy / browser DevTools) can atob(token.split('.')[1]) decode to JSON in one second. JWT is not encryption, it’s “tamper-proof plaintext”.

Fix:

  • Never put PII (Personally Identifiable Information) in JWT payload — no phone / email / ID card / bank card / address / password hash
  • JWT payload only holds “non-sensitive identifiers” — user_id (database UUID, not derivable number), role (role, not org structure), exp / iat / nbf (timestamps)
  • Truly sensitive fields: put in backend database, JWT only holds user_id, query DB when needed
  • If you really need sensitive data in token: use JWE (JSON Web Encryption) — but 99% scenarios don’t need it

Real scenario: A SaaS company put user phone numbers directly in JWT payload (to “save a database query”), 3 months later JWT secret leaked, 2 million users’ phone numbers obtained through one-click payload decoding, GDPR + China Personal Information Protection Law dual compliance fines ¥8 million.

Foot-Gun 2: alg=none Attack

Symptom: Your JWT library is old (jwt-go early version / jsonwebtoken before 4.x / python-jose old version), an attacker constructs header = {"alg": "none"} + arbitrary payload, forges an admin token, your server library accepts, attacker directly gets admin privileges.

Why: Early JWT standard RFC 7519 didn’t mandate server-side validation of alg field, some libraries seeing alg: "none" directly trust payload without verifying signature (because “none” = “no signature”, library skips verification step). This is JWT history’s most classic attack, in 2015 Auth0 and multiple other libraries got hit.

Fix:

  • Force algorithm whitelist (always specify allowed algorithms): jsonwebtoken.verify with algorithms whitelist: [‘HS256’]
  • Always reject alg: "none" (even if RFC compatible)
  • Upgrade to latest JWT library version (fixes known alg=none vulnerabilities)
  • Code review checking verify calls — no specified algorithms parameter is a vulnerability

Real scenario: 2015 Auth0 public report: Node.js jsonwebtoken library versions before 4.2.2 default to accepting alg: "none", any server accepting tokens without upgrading was under attack. After fix all modern libraries mandate specifying algorithms.

Foot-Gun 3: HS256 vs RS256 Public Key Confusion Attack

Symptom: You use RS256 (RSA asymmetric), public key placed on frontend / gateway to verify tokens, but some developer wrote verify function, seeing header alg: "HS256" uses the same public key as HMAC secret to verify. Attacker gets the public key (already public) → uses public key as HMAC secret to sign HS256 token → server uses same public key to “verify”, passes → forged admin token bypasses authentication.

Why: RS256 uses RSA private key to sign, public key to verify; HS256 uses same secret to both sign and verify. If server code confuses “got a key, use it”, attacker can use either type of key to forge tokens. This is the most classic vulnerability in RS256 deployment.

Fix:

  • Always enforce that alg in header matches configured algorithm — algorithm written in config (RS256 service always verify expected alg=RS256)
  • secret / public key strictly separated naming (avoid confusion): const JWT_PRIVATE_KEY = …; // only for signing const JWT_PUBLIC_KEY = …; // only for verifying
  • Code review checking verify function — force algorithm list verify (whitelist), don’t accept token’s self-reported alg
  • Use mature JWT libraries (PyJWT / jsonwebtoken / java-jwt) instead of implementing verify yourself

Real scenario: 2018 a bank’s JWT gateway service used RS256, but verify function had implementation bug: jwt.verify(token, publicKey) didn’t specify algorithms, attacker used public key as HMAC secret to sign HS256 token passing verification, forged $10 million transfer.

Foot-Gun 4: exp Field Not Validated or Leaked Validation

Symptom: Your JWT has exp field (expiration time), assuming automatic validation, but some edge code path on server only checks signature not exp (e.g., a fallback path), expired tokens still accepted, session never expires. User changes password / gets disabled / logs out, token still usable until exp expires.

Why: JWT standard RFC 7519 § 4.1.4 specifies exp is MUST validate, but many libraries don’t force exp validation by default (for compatibility with certain scenarios like “permanent tokens”). If verify call doesn’t explicitly pass ignoreExpiration: false or manually verifies in code instead of using library, exp gets skipped.

Fix:

  • Always explicitly verify exp (don’t rely on defaults): jwt.verify with algorithms: [‘HS256’] and ignoreExpiration: false
  • Short expiration time (access token 15 minutes, refresh token 7-30 days)
  • server clock synced with NTP (avoid clock skew causing token premature expiration / delayed expiration)
  • Client shouldn’t trust local time — use Date.now() / time.time() and server time to calculate exp difference

Real scenario: A SaaS service access token expiration was 7 days, a user changed password but attacker used old token, still able to access all user resources for 7 days. Shortening to 15 minutes + refresh token rotation is industry standard.

Foot-Gun 5: JWT Secret / Private Key Hardcoded in Git Leak

Symptom: You write const JWT_SECRET="my-s...y" in code + commit + push to GitHub (even private repo, employee departure / account theft / internal leak). GitHub secret scanning scans within 30 seconds, JWT secret leaks same as API keys, attacker uses this secret to sign tokens for any user any payload, entire system compromised.

Why: Git history never deletes (git filter-branch only changes branch pointer, old commit objects remain in .git/objects/ for 30 days). JWT secret same level disaster as API key after leak — attacker gets secret = entire auth system compromised = arbitrary forge user identity.

Fix:

  • JWT secret via env vars + .gitignore: const JWT_SECRET=proces…T
  • Never hardcode in code
  • CI uses OIDC / short-term tokens, no long-term secrets
  • JWT secret same as API key rotation + monitoring — batch-24 api-key-leak-prevention 7-day protection plan fully applies

Real scenario: A fintech company jwt-secret written in config/default.json committed to GitHub, GitHub secret scanning 30 seconds email + webhook to attacker, within 1 hour 2 million user JWTs re-signed, user balances arbitrarily tampered. Fix: move secret to Vault / AWS Secrets Manager + CI OIDC.

Foot-Gun 6: kid (Key ID) Injection Attack

Symptom: You use RS256 multi-key architecture, kid field in header specifies which public key to use, server getKey(header.kid) looks up public key table based on kid. Attacker constructs kid: "../../etc/passwd" or kid: "' OR 1=1--", some JWT libraries directly fs.readFileSync(getKey(kid)) or concatenate SQL query, causing arbitrary file read / SQL injection.

Why: kid field meant to be “which key to use for verification”, but libraries implementing getKey often treat kid as filename / SQL parameter / URL path, no whitelist character validation. Attacker uses kid for path traversal (LFI / RCE) or SQL injection.

Fix:

  • kid strict whitelist characters (only allow [A-Za-z0-9_-]): validate kid matches whitelist regex (alphanumeric + dash + underscore, 1-32 chars)
  • kid value must be in known key set (Map.has(kid) check)
  • Don’t treat kid as filename / SQL parameter / URL path
  • Use mature JWT libraries (implementing getKey yourself easy to bug)

Real scenario: Node.js jsonwebtoken + some plugins support jwk header field, attacker constructs jwk: [object with attacker-controlled key], lets server use attacker-provided key to verify = definitely passes. Fix: reject token’s own jwk / jku / x5u fields, only use server-configured key.

Foot-Gun 7: Signature Stripping Attack

Symptom: When you write verify function, if some boundary condition (e.g., header.alg doesn’t match whitelist) not handled, some libraries will fall back to accept token without signature (header.payload. followed by empty string). Attacker modifies payload (e.g., change role to admin) → delete signature → some fallback path skips signature verification → forged token passes validation.

Why: JWT format is header.payload.signature, if only header.payload. passed, library parses signature segment as empty, some library implementations if (!signature) return payload (because “no signature = no need to verify”), bypass entire signature verification.

Fix:

  • Always verify complete 3 segments (header.payload.signature must all have content)
  • Reject tokens with empty signature (even if RFC compatible)
  • Always use mature libraries (don’t split + verify yourself)
  • Test case: construct header.payload. submit, should be rejected

Real scenario: Some JWT library implementation bug: when signature is empty directly return payload, attacker captures a valid token → modifies payload (role → admin) → deletes signature → passes validation → gets admin privileges. Fix: before verify check token.split(’.‘).length === 3 and third segment non-empty.

Foot-Gun 8: JWT Revocation List Doesn’t Exist

Symptom: User changes password / gets disabled by admin / actively logs out, old token still valid until exp expires. Access token 7-day expiration means attacker using old token can still access for 7 days after password change. No blacklist / revoke mechanism = once JWT issued can’t manage it.

Why: JWT designed to be stateless — server doesn’t store token, only verifies signature. Good for horizontal scaling, bad for revocation. If you want to “immediately revoke this token”, must have blacklist or refresh token mechanism.

Fix:

  • Short access token expiration + refresh token rotation: access token: 15 minutes expiration refresh token: 7 days expiration, after each use regenerate new (rotation, old refresh token invalid)
  • refresh token stored in DB + blacklist (mark refresh token as revoked on logout)
  • Revoke all refresh tokens on password change (force re-login)
  • Redis blacklist (high-frequency access scenarios use Redis cache revoked tokens, TTL = token remaining validity)

Real scenario: A SaaS user changed password but attacker used stolen token still accessed for 7 days → user email modified / credit card added → only discovered after customer service complaint. Fix: access token 15 minutes + refresh token rotation + change password revokes all refresh tokens.

5 Real Attack Scenarios

Scenario 1: Phone Number Plaintext Leak

Symptom: A SaaS API returns user JWT containing {user_id: 123, phone: "13812345678", email: "user@example.com"}, frontend uses this token to display “logged in” state. 3 months later JWT secret leaked, all tokens decoded, 2 million users’ phone + email obtained in one batch.

Fix:

  • JWT payload only holds user_id (UUID, not derivable number)
  • When frontend needs to display phone, use user_id to call backend API query (don’t rely on token decode)
  • Never put PII in JWT payload

Scenario 2: alg=none Forged Token

Symptom: An early Node.js service uses jsonwebtoken@4.2.1, verify doesn’t specify algorithms, attacker constructs header = {"alg": "none", "typ": "JWT"} + payload {user_id: 1, role: "admin"} + signature left empty, directly forges admin token passing validation.

Fix:

  • Force algorithms whitelist: jwt.verify(token, secret, { algorithms: ['HS256'] })
  • Upgrade jsonwebtoken to latest version

Scenario 3: HS256 Public Key Confusion Attack

Symptom: A bank’s JWT service uses RS256 (private key signs, public key verifies), public key on API gateway, verify function jwt.verify(token, publicKey) doesn’t specify algorithms. Attacker uses public key as HMAC secret to sign HS256 token, server uses public key to “verify”, passes → forged admin token transfers $10 million.

Fix:

  • verify force algorithms whitelist: jwt.verify(token, publicKey, { algorithms: ['RS256'] })
  • private key / public key strict naming separation (avoid getKey confusion)

Scenario 4: exp Not Validated, Expired Token Still Accepted

Symptom: A service JWT has exp, but verify library default ignoreExpiration: true (for compatibility with “permanent tokens”), all expired tokens pass. User changes password but attacker uses old token still usable for 30 days.

Fix:

  • Explicitly verify exp: jwt.verify(token, secret, { algorithms: ['HS256'], ignoreExpiration: false })
  • Never rely on defaults

Scenario 5: Signature Stripping Attack

Symptom: Some JWT library verify implementation bug: when signature empty directly return payload. Attacker captures valid token → modifies payload → deletes signature → header.payload. submits, bypasses signature verification.

Fix:

  • Before verify check token.split(’.‘).length === 3 and third segment non-empty
  • Always use mature JWT library (split + verify yourself easy to bug)
  • Test case: token = "header.payload." should be rejected

7-Day Protection Plan

Day 1: Force Algorithm Whitelist

5 minutes. Code review checking all JWT verify calls, ensure specify algorithms whitelist:

  • Node.js jsonwebtoken: jwt.verify(token, secret, { algorithms: ['HS256'] })
  • Python PyJWT: jwt.decode(token, secret, algorithms=['HS256'])
  • Java java-jwt: Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
  • Always reject alg=none

Effect: alg=none attack + HS256/RS256 confusion attack fully blocked.

Day 2: JWT Secret via Environment Variables

30 minutes. Move all JWT secret / private key from code / config files to environment variables:

  • Create .env.example (placeholder, no real secret, commit)
  • .gitignore add .env
  • Code change to const JWT_SECRET=proces...
  • CI uses GitHub Actions secrets + OIDC

Effect: Git leak JWT secret risk reduced to zero.

Day 3: Always Verify exp + Short Expiration

1 hour. Code review all verify calls, ensure:

  • ignoreExpiration: false explicitly specified
  • access token expiration ≤ 15 minutes
  • refresh token expiration ≤ 7 days
  • refresh token rotation (regenerate after each use)

Effect: After token leak maximum 15 minutes accessible, not 7 days.

Day 4: kid Strict Whitelist

30 minutes. Code review getKey function, ensure:

  • kid value must match ^[A-Za-z0-9_-]{1,32}$
  • kid value must be in known key set
  • Don’t treat kid as filename / SQL parameter / URL path
  • Reject token’s own jwk / jku / x5u fields (use server-configured key)

Effect: kid injection attack fully blocked.

Day 5: Signature Must Verify

15 minutes. Code review verify pre-checks:

  • token.split(’.‘).length === 3
  • Third segment (signature) non-empty
  • Library version is latest (no known signature stripping vulnerabilities)
  • Test case: token = "header.payload." should be rejected

Effect: Signature stripping attack fully blocked.

Day 6: refresh token Revocation Mechanism

Half day. Implement refresh token rotation + blacklist:

  • Database stores refresh tokens (associated user_id + device + revoked status)
  • After each use of refresh token: generate new, mark old as revoked
  • Detect revoked refresh token being used → revoke all that user’s tokens (suspect token stolen)
  • On password change: revoke all refresh tokens, force re-login
  • Redis cache revoked status (high-frequency access scenarios)

Effect: After logout / password change / account disable old token immediately invalid.

Day 7: JWT Payload Audit

1 hour. Code review all JWT sign calls, ensure payload only contains non-sensitive identifiers:

  • user_id (UUID, not derivable number)
  • role (role, not exposing org structure)
  • exp / iat / nbf (timestamps)
  • Not include: phone / email / ID card / bank card / password hash / address

Effect: Even if token leaks, PII won’t be exposed.

Tool Selection Decisions

Option A: Commercial JWT Services (Auth0 / Clerk / Firebase Auth / AWS Cognito)

Best for: Medium-large teams / multi-product unified auth / strict compliance (finance / healthcare).

Pros: Mature security / OAuth 2.0 + OIDC standard / built-in refresh token rotation / built-in blacklist / compliance audit. Cons: High price (Auth0 MAU > 1000 starts $240/month), vendor lock-in, self-hosting difficult.

Option B: Self-implement with Mature JWT Libraries (PyJWT / jsonwebtoken / java-jwt)

Best for: Individual / small team / startup / MVP.

Pros: Free / flexible / fully controllable / standard compatible. Cons: Self-implement revoke / rotation (easy to bug), need self 7-day protection plan all implemented, no built-in audit.

Option C: Piick JWT Decoder

Best for: Any debug / audit / learning scenario.

100% local with no upload, especially suitable for debug-time checking suspicious tokens / auditing JWT payload / learning JWT structure:

  • Decode header + payload (base64url one-click decode, display plaintext JSON)
  • Parse exp / iat / nbf / aud / iss / sub (display expiration countdown, mark red already expired)
  • Check signature existence + algorithm (warn alg=none)
  • kid / jku / x5u security check (warn suspicious fields)
  • Store nothing (local decode, destroy on page close)

Decision tree:

  • Medium-large / multi-product / compliance → Option A (Auth0 / Clerk)
  • Individual / small team / MVP → Option B (mature library + 7-day protection plan)
  • Any debug / audit → Option C (Piick local decode)
  • Always force JWT algorithm whitelist (algorithms: ['HS256'] or ['RS256']), always reject alg=none
  • Always verify exp (explicit ignoreExpiration: false), access token ≤ 15 minutes
  • Never put PII in JWT payload (no phone / email / ID card / bank card)
  • Always put JWT secret in env vars + .gitignore, never hardcode to Git
  • Always use mature JWT library (PyJWT / jsonwebtoken / java-jwt), don’t implement verify yourself
  • Always use refresh token rotation + blacklist, access token ≤ 15 minutes + refresh token ≤ 7 days + rotation
  • Always kid whitelist characters (only allow [A-Za-z0-9_-]), reject token’s own jwk / jku / x5u
  • Always test signature stripping attack: token = "header.payload." must be rejected
  • Use Piick JWT Decoder locally decode suspicious tokens check header / payload / exp / signature, doesn’t consume tokens no log upload (data 100% local), Piick JSON Formatter beautify JWT payload JSON structure validate schema, Piick Regex Tester write regex validate JWT claim format (iat is number, exp is number, sub is UUID), Piick URL Encoder/Decoder check OAuth callback URL whether contains token in query string, Piick Cron Generator generate JWT rotation schedule — 5-piece set covers all JWT online / debug / security dev scenarios, data 100% local with no upload

JWT is not encryption it’s encoding, signature can only prevent tampering cannot keep secret. True “JWT security” = force algorithm whitelist + verify exp + no PII + secret via env vars + kid whitelist + signature must verify + refresh token rotation + blacklist. Bookmark the Piick JWT Decoder — debug-time check suspicious tokens in 1 minute, daily dev no longer worry about JWT leaks.