Appearance
Sessions
JWT put identity in a Bearer token the SPA stores. Sessions put a session id in a cookie; the server remembers
userIdin a store. Same AuthN job - different place the "logged in" bit lives.
Mental model
| JWT (what we did) | Sessions |
|---|---|
Authorization: Bearer … | Cookie: connect.sid=… |
| Stateless verify (mostly) | Server lookup every request |
| Logout = delete client token | Logout = destroy server session |
| Great for pure APIs / mobile | Great for same-site web apps |
FE map: session cookie ≈ "the server holds the Pinia/Redux auth slice; the browser only holds a key".
Install
bash
npm install express-session
npm install -D @types/express-sessionMemory store is fine for learning. Production wants Redis / Postgres store - memory evaporates on restart and doesn't share across instances.
Wire it up
ts
import session from 'express-session'
import cors from 'cors'
app.use(
cors({
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
credentials: true, // required for cookies cross-origin
}),
)
app.use(
session({
name: 'sid',
secret: process.env.SESSION_SECRET!, // long random string
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS can't read it - XSS harder to steal
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // tighten to 'none' + secure if cross-site
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}),
)Comments for the code above:
credentials: trueon CORS andfetch(..., { credentials: 'include' })on the SPA - both requiredhttpOnlyis the big win vs localStorage JWT- Never commit
SESSION_SECRET
Login / logout
ts
authRouter.post('/login', async (req, res, next) => {
try {
const user = await findUserByEmail(req.body.email)
if (!user || !(await verifyPassword(req.body.password, user.passwordHash))) {
throw new HttpError(401, 'invalid email or password')
}
req.session.userId = user.id
res.json({ user: publicUser(user) }) // no token in the body
} catch (err) {
next(err)
}
})
authRouter.post('/logout', (req, res, next) => {
req.session.destroy((err) => {
if (err) return next(err)
res.clearCookie('sid')
res.status(204).end()
})
})TypeScript tip - extend the session:
ts
// src/types/session.d.ts
import 'express-session'
declare module 'express-session' {
interface SessionData {
userId?: number
}
}requireAuth for sessions
ts
export function requireAuth(req: Request, _res: Response, next: NextFunction) {
if (!req.session.userId) {
return next(new HttpError(401, 'unauthorized'))
}
const user = findUserById(req.session.userId)
if (!user) return next(new HttpError(401, 'unauthorized'))
;(req as AuthedRequest).user = publicUser(user)
next()
}Same route protection idea as JWT - different credential source.
SPA fetch
ts
await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
await fetch('http://localhost:3000/api/me', {
credentials: 'include',
})Forget credentials: 'include' and you'll debug "cookie never sent" for an hour. Ask me how I know. 😅
CSRF (the tradeoff)
Cookies get auto-sent by the browser. That's convenient - and why CSRF exists.
Mitigations (pick a path in real apps):
SameSite=Lax/Strictcookies (helps a lot for simple cases)- CSRF tokens (double-submit or synchronizer) for state-changing POSTs
- Separating API subdomain carefully
Bearer JWT in Authorization header isn't auto-attached by random sites - different threat model.
JWT vs sessions - pick one story per app
Mixing both without a plan confuses everyone. For this tutorial:
- notes-api stays JWT (API-first, easy curl)
- this chapter is the cookie-session alternate path
Same-site "Express serves the SPA build" apps often prefer sessions. Separated Vite + API often prefer JWT or a BFF.
Companion
examples/notes-api does not switch to sessions (would break the JWT chapters). Copy the snippets above into a throwaway branch if you want to feel the cookie flow end-to-end.
Next: uploading and managing files - the CRUD that isn't JSON.
Best,
-- ł.