Appearance
Register & login
Register writes a user row. Login checks the hash and returns a JWT. The SPA stores the token and sends it on later requests - same pattern as many API-first backends.
JWT in one paragraph
A JWT is a signed blob: header + payload + signature. Server signs with JWT_SECRET. Client sends Authorization: Bearer <token>. Server verifies signature and trusts sub (user id) inside.
Cookie sessions are the other classic path (express-session). For SPA + separate API origins, Bearer JWT is the common teaching path - so that's what we use. Sessions aren't "wrong"; they're just a different chapter.
Install
bash
npm install jsonwebtoken
npm install -D @types/jsonwebtokenIssue a token
ts
import jwt from 'jsonwebtoken'
import { config } from './config.js'
export function signToken(userId: number) {
return jwt.sign({ sub: userId }, config.jwtSecret, { expiresIn: '7d' })
}Payload stays tiny. Don't stuff email, roles, and life story into every token unless you enjoy invalidation headaches.
Register route
ts
// POST /api/auth/register { email, password }
authRouter.post('/register', async (req, res, next) => {
try {
const email = String(req.body?.email ?? '').trim()
const password = String(req.body?.password ?? '')
if (!email.includes('@')) throw new HttpError(400, 'invalid email')
if (password.length < 8) throw new HttpError(400, 'password too short')
const user = await createUser(email, password)
const token = signToken(user!.id)
// optional: kick off verification email here (next chapters)
res.status(201).json({ token, user: publicUser(user!) })
} catch (err) {
next(err)
}
})Login route
ts
authRouter.post('/login', async (req, res, next) => {
try {
const email = String(req.body?.email ?? '').trim()
const password = String(req.body?.password ?? '')
const user = findUserByEmail(email)
if (!user || !(await verifyPassword(password, user.password_hash))) {
throw new HttpError(401, 'invalid email or password')
}
const token = signToken(user.id)
res.json({ token, user: publicUser(user) })
} catch (err) {
next(err)
}
})Comments for the code above:
- Same error message for "no user" and "bad password" - don't help attackers enumerate emails
- Return token + public user so the SPA can hydrate a store in one round-trip
- Status 201 on register, 200 on login
SPA side (sketch)
ts
const res = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error((await res.json()).error)
const { token, user } = await res.json()
localStorage.setItem('token', token) // fine for learning; httpOnly cookies are another designLater requests:
ts
fetch('http://localhost:3000/api/me', {
headers: { Authorization: `Bearer ${token}` },
})Try it against the companion
bash
cd examples/notes-api && npm run dev
curl -s -X POST http://localhost:3000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"password1"}'You should get back a token and user. Keep that token for the next chapter.
What we still skip (on purpose)
- Refresh tokens / rotation
- "Logout" as server-side revoke (stateless JWT logout is mostly client-side delete)
- Email verification gate before login
Verification emails come in Registration emails. First: middleware that actually reads the Bearer token.
Best,
-- ł.