JWT vs Session Authentication: Choosing the Right Approach


JWT vs sessions is not a religion. It is a tradeoff about where you store trust — server memory vs signed client payload — and how fast you need to revoke access.
I have shipped both. I have regretted neither when the choice matched the architecture. I have regretted picking based on a Medium headline.
For cookie and Server Component nuances in Next.js, pair this with use client vs use server.
| Situation | What I lean toward |
|---|---|
| Traditional web app, same domain, need logout now | Sessions (httpOnly cookie) |
| Mobile app + API, multiple services | JWT (+ refresh token) |
| SPA on separate domain from API | JWT or session with careful CORS/cookie setup |
| Banking / healthcare / immediate revoke | Sessions or very short JWT + server-side denylist |
| Microservices, no shared session store | JWT with shared signing keys |
Still fuzzy? Default to sessions in httpOnly cookies for a monolith web app. Default to short access JWT + refresh for API-first or mobile.
What: Server stores session; client gets a session ID (usually in a cookie).
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.userId = user.id;
req.session.save();
res.json({ message: "Login successful" });
});
app.get("/profile", requireAuth, (req, res) => {
res.json({ user: req.user });
});
Why I like it:
Costs:
Red flags:
httpOnly, secure, sameSiteWhat: Signed token on the client; server verifies signature each request.
import jwt from "jsonwebtoken";
app.post("/login", async (req, res) => {
const user = await validateUser(req.body);
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({ token });
});
function authenticateToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token" });
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: "Invalid token" });
req.user = user;
next();
});
}
Why I like it:
Authorization headerCosts:
Red flags:
expWhat: Short access token (15m) + long refresh token stored server-side.
app.post("/login", async (req, res) => {
const user = await validateUser(req.body);
const accessToken = jwt.sign({ userId: user.id }, process.env.ACCESS_TOKEN_SECRET, {
expiresIn: "15m",
});
const refreshToken = jwt.sign({ userId: user.id }, process.env.REFRESH_TOKEN_SECRET, {
expiresIn: "7d",
});
await RefreshToken.create({ userId: user.id, token: refreshToken });
res.json({ accessToken, refreshToken });
});
Why: Balance stateless APIs with revocable refresh tokens.
Gotcha: Store refresh tokens hashed; rotate on use; detect reuse.
Sessions:
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
sameSite: "strict",
},
})
);
JWT:
jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: "15m",
issuer: "your-app",
audience: "your-app-users",
});
Name your app shape: monolith web, SPA + API, mobile, microservices. Pick session, JWT, or hybrid from the table — not from Twitter.
Auth method is half the story. Implementation is the other half: HTTPS, httpOnly cookies, short TTLs, rotation, and a plan for stolen tokens.
Then read how auth cookies interact with Server Components in use client vs use server.