Best practices for JWT Authentication
Demystifying JWT Authentication for Modern Web Apps
Authentication is the cornerstone of web security, yet it remains one of the most misunderstood and incorrectly implemented features in modern application development. In the era of Single Page Applications (SPAs) built with React, Next.js, and Vue, traditional session-based authentication has largely been replaced by JSON Web Tokens (JWT).
However, moving to JWTs introduces a host of new security vulnerabilities if not handled with absolute precision. In this extensive guide, we will explore the best practices for JWT authentication, the critical differences between LocalStorage and HttpOnly cookies, and how to protect your users from modern cyber threats.
What is a JSON Web Token?
A JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
A standard JWT consists of three parts separated by dots (.):
- Header: Contains the algorithm used (e.g., HMAC SHA256 or RSA).
- Payload: Contains the claims (the actual user data, like user ID and roles).
- Signature: The cryptographic hash that verifies the token hasn't been altered.
You can actually decode and inspect the contents of any JWT (without needing the secret key) using our free JWT Decoder Tool.
The Fatal Flaw: Storing JWTs in LocalStorage
When a developer builds their first React application and connects it to a Node.js API, the typical tutorial tells them to take the JWT returned from the login endpoint and save it using localStorage.setItem('token', jwt).
This is a massive security risk.
localStorage is accessible via JavaScript. If your website has a Cross-Site Scripting (XSS) vulnerability—which can happen simply by installing a compromised NPM package or rendering un-sanitized user input—a hacker can write a script that reads localStorage and sends your users' tokens to their server.
Once a hacker has the JWT, they have full access to that user's account until the token expires.
The Solution: HttpOnly Secure Cookies
To defend against XSS attacks, you must completely remove the token from JavaScript's reach.
When your server generates the JWT after a successful login, it should not send the token in the JSON response body. Instead, the server should set an HttpOnly cookie in the response headers:
Set-Cookie: access_token=eyJhbGciOiJIUzI1Ni...; HttpOnly; Secure; SameSite=Strict; Max-Age=900
- HttpOnly: Prevents client-side JavaScript (like
document.cookie) from reading the cookie. This makes XSS token theft impossible. - Secure: Ensures the cookie is only transmitted over HTTPS encrypted connections.
- SameSite=Strict: Protects against Cross-Site Request Forgery (CSRF) by ensuring the browser only sends the cookie for requests originating from your exact domain.
When the browser makes subsequent API requests, it automatically attaches this cookie. Your frontend code never touches the token.
Implementing Refresh Tokens
JWTs are stateless. This means once a token is issued, the server doesn't keep a record of it. The server only checks if the cryptographic signature is valid and if the expiration time hasn't passed.
Because of this statelessness, you cannot easily revoke a JWT. If an attacker steals a token, they can use it. Therefore, the access_token must have a very short lifespan—typically 10 to 15 minutes.
But forcing users to log in every 15 minutes is terrible UX. This is where Refresh Tokens come in.
- Upon login, the server issues two tokens: a short-lived
access_token(15 mins) and a long-livedrefresh_token(7 days). - Both are stored in HttpOnly cookies.
- When the
access_tokenexpires, the API returns a401 Unauthorized. - Your frontend catches this
401, and transparently sends a request to a/refreshendpoint. - The server validates the
refresh_token, checks the database to ensure the user hasn't been banned or logged out, and issues a newaccess_tokencookie.
Unlike access tokens, refresh tokens are stored in the database, meaning you can instantly revoke them by deleting the database record.
Protecting Against CSRF Attacks
While moving tokens to cookies solves XSS, it potentially opens you up to Cross-Site Request Forgery (CSRF). If a user is logged into your bank app, and visits a malicious website, the malicious site could try to submit a hidden form to your bank's API. Because the browser automatically attaches cookies, the request might succeed!
To prevent this:
- Ensure
SameSite=StrictorSameSite=Laxis set on your cookies. - For critical actions (like changing passwords or transferring money), implement anti-CSRF tokens—a unique, hidden value generated by the server and verified on submission.
- Avoid using GET requests for any state-changing operations.
Generating Strong Secrets and Passwords
Your JWTs are only as secure as the secret key used to sign them. If you use a weak secret like "my_super_secret_key", attackers can run brute-force offline dictionary attacks to guess the key and forge their own admin tokens.
Always generate cryptographically secure, high-entropy secrets (at least 64 characters of random hex). Furthermore, encourage your users to use strong passwords. You can direct them to our Secure Password Generator to create unbreakable credentials.
Conclusion
Building a secure authentication system is complex, but non-negotiable. By moving away from localStorage, embracing HttpOnly cookies, implementing proper refresh token rotation, and generating strong secrets, you can protect your users' data from modern cyber threats.
If you're building a new project, consider using established Auth providers like Supabase Auth or NextAuth.js rather than rolling your own crypto. They handle the complex cookie mechanics and refresh logic out of the box, letting you focus on building your actual product.
