Securing APIs With JWT and Refresh Tokens
Cover photo by Vishnu Mohanan on Unsplash
So, you are looking at storing your JWT in localStorage and hoping for the best. Stop. That is a recipe for XSS attacks that will expose your users before you finish your coffee. I prefer storing JWTs in memory and using a silent refresh flow with HttpOnly cookies.
Why not just use long-lived tokens?
“But it is so much easier to just give the user a token that lasts for a week.”
You are right, it is easier. The trade-off is that once that token is stolen, you have no way to kill that session until it naturally expires. If a user loses their laptop or a malicious actor scrapes their storage, you are in trouble. Using short-lived access tokens (lasting maybe 15 minutes) significantly limits the window of opportunity for an attacker.
How the flow works
When the access token dies, your frontend hits a dedicated refresh endpoint. This endpoint checks a refresh token, which should be stored in an HttpOnly, Secure, SameSite cookie. This prevents JavaScript from reading the token directly. If the cookie is valid, you issue a new access token.
// Simple refresh logic exampleasync function getNewToken() { const response = await fetch('/api/refresh', { method: 'POST' }); if (!response.ok) throw new Error('Session expired'); const { accessToken } = await response.json(); return accessToken;}Is this foolproof?
“This seems overkill for a simple personal project.”
It might be. If your project has no sensitive data, maybe just use a simple session ID in a cookie. I sometimes skip this complexity for internal tools that never hit the public internet (though even then, I tend to regret it later). Don’t over-engineer if you don’t have to.
Handling the refresh race condition
One tricky part is when multiple components try to refresh the token at the same time. You need a way to queue those requests so you don’t burn through your refresh tokens. I usually use a promise that resolves once the first refresh request finishes.
let refreshPromise = null;
async function fetchWithToken(url, options) { if (!refreshPromise) { refreshPromise = getNewToken().finally(() => { refreshPromise = null; }); } const token = await refreshPromise; // Proceed with your request headers here}By keeping the access token in a local variable (like a React ref or a state manager), it never hits the disk. This is a solid, industry-standard approach. It keeps your app responsive while making sure you aren’t leaving the front door wide open for script-based attacks. Does this solve every single security problem? No, but it keeps the low-hanging fruit out of reach. What part of your auth flow feels the most fragile right now?