← All Articles
Security

How JWT Authentication Works — A Developer's Guide

JSON Web Tokens (JWT, pronounced "jot") are the most widely used mechanism for stateless authentication in modern web applications and APIs. If you have built or consumed a REST API in the last several years, you have almost certainly encountered the Authorization: Bearer <token> header. But what exactly is inside that token, how is it verified, and what are the security pitfalls? This guide explains JWTs from first principles.

The three-part structure

A JWT is a string of three Base64URL-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBcmp1biBTaGFybWEiLCJleHAiOjE3MzU2ODk2MDB9
.HVOd6F5gIt_Y_bM9eC0IP4dc8ENdJ4hzF8Tks_68oZY

That signature is a real HMAC-SHA256 over the header and payload above, using the secret your-256-bit-secret, so you can paste the token into any verifier and reproduce it. Each section decodes to something meaningful:

  • Header — algorithm and token type: {"alg":"HS256","typ":"JWT"}
  • Payload — claims about the user: {"sub":"user_123","name":"Arjun Sharma","exp":1735689600}
  • Signature — cryptographic proof that the header and payload have not been tampered with

The signature is computed from Base64URL(header) + "." + Base64URL(payload) using the signing algorithm specified in the header (HS256, RS256, ES256, etc.). Without the correct key, it is computationally infeasible to forge a valid signature.

Standard claims (the payload)

The JWT specification defines a set of registered claim names that have widely understood meanings. You don't have to use all of them, but the most important ones are:

  • sub (Subject) — the user or entity the token refers to, typically a user ID
  • iss (Issuer) — the service that issued the token (e.g. "auth.myapp.com")
  • aud (Audience) — the service(s) the token is intended for
  • exp (Expiration Time) — Unix timestamp at or after which the token must be rejected
  • iat (Issued At) — Unix timestamp when the token was created
  • nbf (Not Before) — Unix timestamp before which the token must be rejected
  • jti (JWT ID) — unique identifier for the token, used for revocation

You can add any custom claims you need alongside these. A common pattern in microservices is to include roles or permissions: {"sub":"user_123","roles":["admin","user"]}.

Signing algorithms: HS256 vs RS256 vs ES256

The choice of algorithm determines what kind of key is used to sign and verify the token.

  • HS256 (HMAC + SHA-256) — uses a single shared secret key for both signing and verification. Simple to set up. Both the issuer and every consumer must have the same secret, which makes it unsuitable for scenarios with multiple independent services unless the secret is securely shared.
  • RS256 (RSA + SHA-256) — uses an asymmetric key pair. The issuer signs with the private key; consumers verify with the public key. The public key can be distributed openly (e.g. via a JWKS endpoint) without compromising security. This is the standard choice for multi-service architectures and OAuth 2.0 / OpenID Connect.
  • ES256 (ECDSA + SHA-256) — also asymmetric, but uses Elliptic Curve cryptography. Produces smaller signatures than RSA with equivalent security. Increasingly popular in performance-sensitive environments.

JWT in Spring Boot

Which library you reach for depends on what you are building. If you are issuing your own tokens, jjwt (Java JWT) is a popular standalone choice with a readable builder API. If you are consuming tokens from an identity provider, Spring Security's own resource-server support does the work for you — and under the hood it uses Nimbus, not jjwt: "Spring Security uses the Nimbus library for parsing JWTs and validating their signatures." Here is how to generate and verify a JWT with jjwt:

<!-- Add to pom.xml. All three are needed: with jjwt-api alone the code
     compiles and then fails at run time looking for an implementation. -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.3</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.3</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.12.3</version>
  <scope>runtime</scope>
</dependency>

// Generate a token
String token = Jwts.builder()
    .subject(userId)
    .issuedAt(new Date())
    .expiration(new Date(System.currentTimeMillis() + 86_400_000)) // 24h
    .signWith(secretKey)
    .compact();

// Verify and parse
Claims claims = Jwts.parser()
    .verifyWith(secretKey)
    .build()
    .parseSignedClaims(token)
    .getPayload();

String parsedSubject = claims.getSubject();

For Spring Security integration, implement a OncePerRequestFilter that reads the token from the Authorization header, verifies it, and sets the SecurityContextHolder with a UsernamePasswordAuthenticationToken.

Common security mistakes

1. Storing JWTs in localStorage

localStorage is accessible to any JavaScript running on your page, making tokens stored there vulnerable to XSS attacks. Prefer HttpOnly cookies, which cannot be read by JavaScript. Be aware that this is a trade, not a free win: an HttpOnly cookie protects the token's confidentiality but the browser attaches it to requests automatically, so you have moved from an XSS exposure to a CSRF one — pair it with SameSite and/or anti-CSRF tokens. If you must use localStorage, ensure all third-party scripts are trusted and apply a strict Content Security Policy.

2. Not validating the algorithm

The infamous "alg:none" attack exploits libraries that accept an unsigned token when the header specifies "alg":"none". Always explicitly specify which algorithms are accepted when parsing — never accept the algorithm declared in the token header blindly.

3. Setting expiry too long

A stolen JWT is valid until it expires. The usual answer is short-lived access tokens combined with longer-lived refresh tokens: access tokens are used for API calls, refresh tokens are used to obtain new access tokens when they expire. No specification puts a number on "short-lived" — the lifetime comes out of your own risk model, weighing the damage a stolen token can do against how often you are willing to make clients refresh.

4. Putting sensitive data in the payload

JWTs are signed but not encrypted by default. Anyone who intercepts the token can decode the payload (Base64URL decoding requires no key). Never put passwords, credit card numbers, or other sensitive data in the payload. For encrypted JWTs, look at JWE (JSON Web Encryption).

Decoding a JWT manually

You can decode the header and payload of any JWT without the signing key — just split on dots and Base64URL-decode each part:

// JavaScript
const [header, payload] = token.split('.');
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
// atob() returns a Latin-1 binary string, so decode the bytes as UTF-8 —
// otherwise any non-ASCII claim value comes back mojibaked.
const json = new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0)));
const decoded = JSON.parse(json);
console.log(decoded); // { sub: 'user_123', exp: 1735689600, ... }

// Python
import base64, json
payload = token.split('.')[1]
# Add padding if needed
payload += '=' * (4 - len(payload) % 4)
print(json.loads(base64.urlsafe_b64decode(payload)))

Try it in the browser