Guide to Auth Tokens
1.1 Why do we need tokens at all?
HTTP is stateless. Every request arrives with no memory of the one before it, so the server has no idea that the person calling /api/orders is the same one who logged in a minute ago.
The most obvious fix is to send the email and password with every request. It works, but it causes real problems:
The password travels everywhere. Every call carries your most sensitive secret, so every proxy, log line and browser extension becomes a place it can leak from.
Every request pays the password cost. Passwords are stored with deliberately slow hashes (bcrypt, argon2). Verifying one on every API call burns CPU and slows everything down.
No expiry. A leaked password works until the user changes it.
No revoking one device. To log out a stolen phone, you'd have to change the password for every device.
No limits. The password is all-powerful. You can't hand a client "read-only access for 15 minutes."
So the goal is to send the password once, then carry something else that proves we already logged in. There are two common ways to do that:
| Approach | How it works | Trade-off |
|---|---|---|
| Server-side session | Server stores the session and gives the client an ID | The server must keep session state, and every server behind your load balancer needs access to the same store |
| Token (e.g. JWT) | Server signs a small piece of data and gives it to the client | Stateless and easy to use across services and mobile apps, but harder to take back |
Neither is wrong. Tokens are the usual choice for APIs, mobile apps and multi-service setups, and that's what this guide builds.
1.2 The access token
After a successful login, the server signs a small piece of data and hands it back. That's a JWT.
import jwt from "jsonwebtoken";
app.post("/api/login", async (req, res) => {
const user = await verifyCredentials(req.body);
if (!user) return res.status(401).send("Invalid credentials");
const accessToken = jwt.sign(
{ sub: user.publicId, role: user.role },
process.env.ACCESS_SECRET,
{ expiresIn: "15m" }
);
res.json({ accessToken });
});
The client sends it as Authorization: Bearer <token>, and the server checks the signature:
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.sendStatus(401);
try {
req.user = jwt.verify(token, process.env.ACCESS_SECRET);
next();
} catch {
res.sendStatus(401); // expired or tampered: client should refresh or re-login
}
}
The server stores nothing. It only checks that its own signature is intact. That's the appeal of stateless auth, and also the catch: you can't take a token back. If someone steals it, it works until it expires.
1.3 So make it short-lived
That's why the token above expires in 15 minutes. But now users must log in every 15 minutes, which nobody will tolerate.
1.4 The refresh token
Add a second, long-lived token whose only job is to get new access tokens. It never calls your API directly.
const refreshToken = jwt.sign(
{ sub: user.publicId },
process.env.REFRESH_SECRET,
{ expiresIn: "7d" }
);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/api/refresh",
});
If you think of the access token as a closure over the user's identity that dies quickly, the refresh token is the function that re-creates it.
One thing most tutorials skip is rotation. A stolen refresh token is effectively a 7-day password. So every time it's used, issue a new one and invalidate the old one. If an already-used refresh token shows up again, someone has a copy, so revoke that whole session. That requires storing something server-side (a token ID or hash), which means this part is no longer stateless. That's fine. Pretending it can be is how you end up with unrevokable sessions.
1.5 Where we are
We now have two tokens: a short-lived one that opens doors, and a long-lived one that only makes new short-lived ones. The login flow works.
Comments
Join the discussionNo comments yet. Be the first to comment.



