JSON Web Tokens (JWTs) have become the default choice for stateless authentication in modern web applications. By embedding the user's session claims directly inside a cryptographically signed token, backends can authenticate requests without querying a database or cache on every API call.
However, statelessness introduces unique security vulnerabilities. When developers implement JWT authentication incorrectly, they open the door to critical token theft and forgery attacks. Let's outline the core security rules for deploying JWTs safely in production.
1. Never trust the "alg" header implicitly
A JWT consists of three parts separated by dots: a Header, a Payload, and a Signature. The Header contains the `alg` field, telling the verification library which algorithm was used to sign the token (e.g., `HS256` or `RS256`).
Historically, many library implementations had a critical flaw: they read the header, and if they saw `"alg": "none"`, they skipped the signature verification step entirely, accepting the token as valid. An attacker could simply modify a payload (e.g., changing `"role": "user"` to `"role": "admin"`) and send the token with `alg: none`.
2. Choose the right algorithm: HMAC vs. RSA
There are two primary ways to sign a JWT:
- Symmetric Signing (HMAC-SHA256 / HS256): Uses a single shared secret key to both sign and verify the token. This is simple and fast, but it means any service verifying the token must possess the signing secret. If a third-party service compromises the secret, they can forge tokens.
- Asymmetric Signing (RSA-SHA256 / RS256): Uses a private/public key pair. The authorization server signs tokens with its private key, while microservices verify the signature using the publicly accessible public key. This is the gold standard for distributed architectures because public keys can be distributed safely without compromising signing capability.
3. Secure browser storage: localStorage vs. HttpOnly Cookies
Once a client receives a JWT, where should it be stored? There is an ongoing debate between localStorage and cookies:
- localStorage: Easy to access via JavaScript, making frontend integration trivial. However, it is highly vulnerable to **Cross-Site Scripting (XSS)**. If an attacker injects a malicious script (via an unsanitized input or a compromised npm dependency), they can read `localStorage.getItem('token')` and steal the user's session.
- HttpOnly Cookies: When a token is returned in a `Set-Cookie` header with the `HttpOnly` and `Secure` flags, browser JavaScript cannot read or access the cookie. This completely mitigates XSS token theft. However, it exposes the app to **Cross-Site Request Forgery (CSRF)**.
The Best Strategy: Store JWTs in an `HttpOnly`, `Secure`, `SameSite=Strict` cookie. To prevent CSRF attacks, implement CSRF double-submit tokens or include custom HTTP headers (like `X-Requested-With`) that browser forms cannot forge automatically.
4. Implement short Expirations and Refresh Tokens
Because JWTs are stateless, they cannot be easily revoked before they expire. If a user logs out, or if a user's token is stolen, the token remains valid until the `exp` claim timestamp is reached.
To minimize the window of compromise:
- Set the access token's lifespan to be very short (e.g., 5 to 15 minutes).
- Issue a cryptographically secure, random **Refresh Token** stored in a secure cookie on the auth server.
- When the access token expires, the client uses the refresh token to request a new access token. If a session needs to be revoked, the auth server simply deletes the refresh token from its database.