Skip to main content
C
CodeUtil

Understanding JWT Claims and Best Practices for Secure Token Authentication

Learn how JWT claims work, explore registered, public, and private claims, and discover security best practices for implementing JSON Web Tokens in your applications.

2024-05-2012 min
Related toolJWT Decoder

Use the tool alongside this guide for hands-on practice.

I thought I understood JWTs until I had to debug one

I've been using JWTs for years, but it wasn't until a production incident at Šikulovi s.r.o. that I really understood how claims work. We had tokens that stopped working randomly - turned out our clock was 90 seconds ahead of the auth server, and we weren't accounting for clock skew in our exp validation. Three hours of debugging for a 90-second offset.

Claims are just the data inside a JWT - who you are, what you can do, when the token expires. Simple concept, lots of ways to mess it up. Here's what I've learned the hard way about getting them right.

JWT anatomy in 30 seconds

A JWT is three parts separated by dots: header.payload.signature. The payload is where your claims live. Everything is Base64URL encoded, so you can decode and read it (yes, anyone can read your claims - they're signed, not encrypted).

  • Header: Token type and algorithm (e.g., HS256, RS256)
  • Payload: Your claims - the actual data
  • Signature: Proves the token was not tampered with
  • Format: eyJhbG...eyJzdW...SflKxw (those three base64 strings)

The seven registered claims you should know

These are the 'official' claims defined in RFC 7519. You don't have to use all of them, but you should probably use most of them. I always include exp, iat, and sub at minimum.

  • iss (issuer): Who created this token (e.g., "https://auth.myapp.com")
  • sub (subject): Who is this token about - usually the user ID
  • aud (audience): Who should accept this token - your API endpoint
  • exp (expiration): When does this token die - ALWAYS SET THIS
  • nbf (not before): When does this token become valid
  • iat (issued at): When was this token created
  • jti (JWT ID): Unique ID for this specific token - useful for revocation

Custom claims: public vs private

Beyond the registered claims, you'll add your own. The spec distinguishes between 'public' claims (registered with IANA or namespaced with URIs) and 'private' claims (whatever you want). In practice, most of us just use private claims and hope we don't collide with anything.

  • email, name, picture: Common but technically unregistered
  • role, permissions: Your app-specific authorization data
  • organization_id: Multi-tenant app identifier
  • Pro tip: Don't conflict with registered claims - don't name something 'exp' or 'sub'

Time claims: where most bugs live

exp, iat, nbf - these three time claims cause more production issues than everything else combined. They're all Unix timestamps (seconds since 1970), and the most common mistake is forgetting about clock skew between servers.

  • exp (expiration): ALWAYS set this. 15-60 minutes for access tokens
  • iat (issued at): Useful for detecting old tokens
  • nbf (not before): Rarely used, but prevents tokens from being used too early
  • Clock skew: Allow 30-60 seconds tolerance when validating
  • Pro tip: I've seen clocks drift by minutes. NTP is your friend.

Claim patterns I use in production

After building auth for dozens of projects at Šikulovi s.r.o., here's what I actually put in different token types:

  • Access token: Short exp (15 min), includes sub, aud, scope/permissions
  • Refresh token: Longer exp (7 days), minimal claims, jti for revocation
  • ID token (OIDC): Includes user profile claims (name, email, picture)
  • Service-to-service: Includes iss, aud, iat, exp for API authentication

The security rules I never break

After one security audit that found we were putting user emails in tokens (not a huge deal, but not great either), I made a checklist. Now I follow it religiously:

  • Never put sensitive data in claims - anyone can decode the payload
  • Always validate exp, iat, nbf on the server - client time cannot be trusted
  • Verify iss matches exactly - a different issuer is a red flag
  • Check aud - is this token actually meant for my service?
  • Keep tokens small - they travel with every request

HS256 vs RS256: when to use which

This confused me for years. HS256 uses a shared secret (same key signs and verifies). RS256 uses public/private keys (private signs, public verifies). If only one service creates and validates tokens, HS256 is simpler. If multiple services need to validate, RS256 is better because you don't have to share secrets.

  • HS256: Simple, one secret, both sides need the same key
  • RS256: Auth server has private key, everyone else has public key
  • ES256: Like RS256 but smaller keys, same security
  • NEVER use "none" algorithm - I mean never, not even in dev
  • My default: RS256 for anything beyond a single-service app

How long should tokens live?

I used to set tokens to expire in 24 hours because logging in frequently annoyed me. Then I realized: if a token is stolen, the attacker has 24 hours to use it. Now I use 15-minute access tokens with 7-day refresh tokens. Users stay logged in, but stolen access tokens are useless quickly.

  • Access tokens: 15-60 minutes (I prefer 15)
  • Refresh tokens: 7-30 days (depends on sensitivity)
  • Sliding expiration: Token extends with activity
  • Absolute expiration: Force re-login after X days regardless

The JWT revocation problem

Here's the dirty secret of JWTs: they're stateless, which means you can't really revoke them. Once issued, a token is valid until it expires. This bit me when a user reported their account was compromised and I realized I couldn't force logout. Here's how I handle it now:

  • Short expiration: Simplest fix - 15 minutes max damage window
  • Token blacklist: Store revoked jti in Redis, check on each request
  • Token versioning: Include version claim, bump on password change
  • Refresh token rotation: New refresh token each time, revoke old one

My validation checklist

Every API endpoint that accepts JWTs runs through this checklist. I've seen too many apps that only check the signature and skip everything else.

  • 1. Verify signature - if this fails, stop immediately
  • 2. Check exp > now (allow 60 seconds clock skew)
  • 3. Check nbf < now if present
  • 4. Validate iss === expected issuer
  • 5. Validate aud includes this service
  • 6. Check required custom claims exist

Mistakes I see in every security audit

I've done security reviews for a lot of apps, and these JWT mistakes show up constantly. If you're making any of these, fix them today:

  • localStorage for tokens: XSS can steal them. Use HttpOnly cookies.
  • Reading claims before verifying signature: Attackers can forge claims
  • Weak secrets: "secret123" is not a secret. Use 256+ bit random keys.
  • Trusting the alg header: Attackers set it to "none" and bypass verification
  • 24-hour expiration: That is 24 hours of damage if stolen
  • Email/PII in tokens: Anyone can decode them, remember?

Where to store tokens (the real answer)

The localStorage vs cookie debate comes up constantly. Here's my take: for web apps, HttpOnly cookies win. They're invisible to JavaScript, which means XSS can't steal them. Yes, you need CSRF protection, but that's solvable.

  • HttpOnly cookies: My default for web apps. XSS-proof.
  • Memory only: Most secure for SPAs, but lost on refresh
  • localStorage: NEVER for auth tokens. Stop doing this.
  • Mobile apps: Keychain (iOS) or Keystore (Android)

When tokens break (debugging guide)

Token validation failing? Here's my debugging flow. I built the JWT Decoder tool specifically because I was tired of copy-pasting tokens into random websites to debug them.

  • 1. Decode the token - check if claims look right
  • 2. Check exp - is it in the past?
  • 3. Compare clocks - is the server ahead/behind?
  • 4. Verify algorithm - does the server expect RS256 but you sent HS256?
  • 5. Check the key - is it the right secret/public key?
  • 6. Look at encoding - proper Base64URL, no extra characters?

FAQ

What is the difference between registered and private claims?

Registered claims are predefined by the JWT specification (like exp, iss, sub) and have standard meanings. Private claims are custom claims you define for your application (like user_id or role). Registered claims ensure interoperability, while private claims let you include application-specific data.

Should I encrypt my JWT payload?

JWTs are signed, not encrypted by default. Anyone can decode and read the payload. If you need to hide claim values, use JWE (JSON Web Encryption) or avoid putting sensitive data in claims entirely. For most applications, signing alone is sufficient when combined with HTTPS.

How long should JWT tokens be valid?

Access tokens should be short-lived, typically 15-60 minutes. Refresh tokens can be longer (days to weeks) but require secure storage and revocation capability. Shorter expiration times limit the damage if a token is compromised.

What happens if a JWT is stolen?

A stolen JWT can be used by an attacker until it expires. This is why short expiration times are important. Implement token revocation through blacklists or refresh token rotation. Also use secure storage (HttpOnly cookies) to prevent theft through XSS.

Can I change claims in an existing JWT?

No. JWTs are immutable once signed. Any modification to the header or payload invalidates the signature. To change claims, you must issue a new token with the updated values.

Why use sub instead of user_id for the user identifier?

The sub (subject) claim is the registered claim for identifying the principal. Using it ensures interoperability with libraries and services that expect standard claims. You can still use custom claims like user_id alongside sub if needed.

How do I handle JWT expiration gracefully?

Use refresh tokens to obtain new access tokens before expiration. Implement token refresh logic that detects near-expiration and refreshes proactively. Handle 401 responses by attempting refresh before prompting for re-login.

What is the maximum size for JWT claims?

There is no hard limit in the specification, but JWTs are sent with every request (often in headers). Keep the total JWT size under 8KB to avoid issues with header size limits in servers and proxies. Minimize claims to essential data only.

Martin Šikula

Founder of CodeUtil. Web developer building tools I actually use. When I'm not coding, I experiment with productivity techniques (with mixed success).

Related articles

12 min

Secure Password Hashing - MD5, SHA-256, and Beyond

Learn why password hashing matters, why MD5 is broken for security, how SHA-256 differs, and why bcrypt and Argon2 are the right choice for storing passwords. Understand rainbow tables, salting, and modern best practices.

Hash Generatorsecurityhashingpasswordscryptography
14 min

JWT Tokens Explained - Authentication for Modern Web Apps

Understand JSON Web Tokens (JWT) from the ground up: how they work, their three-part structure, when to use them, security best practices, refresh token strategies, and common implementation mistakes to avoid.

JWT Decoderjwtauthenticationsecurityweb development