Skip to content

Sessions ​

JWT put identity in a Bearer token the SPA stores. Sessions put a session id in a cookie; the server remembers userId in 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 tokenLogout = destroy server session
Great for pure APIs / mobileGreat 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-session

Memory 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: true on CORS and fetch(..., { credentials: 'include' }) on the SPA - both required
  • httpOnly is 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 / Strict cookies (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,

-- ł.

Notes-style Express tutorial for front-end folks