Implementing JWT Auth
With the JWT concepts in hand, let's build a working flow in Express: a login endpoint that signs a token, middleware that verifies it on protected routes, and the error handling that keeps it secure. We'll use jsonwebtoken, the de facto library. The whole pattern is small — the discipline is in pinning the algorithm, setting a short expiry, returning the right status codes, and never leaking why verification failed.
Install and configure
npm install jsonwebtoken bcrypt
config — load the secret from the environment, never hard-code it
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET // long, random, secret
const JWT_EXPIRES_IN = '15m' // short-lived access token
if (!JWT_SECRET) throw new Error('JWT_SECRET is not set')Login: verify credentials, sign a token
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await db.users.findByEmail(email)
// Generic failure — don't reveal which part was wrong:
const ok = user && await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'Invalid email or password' })
// Sign minimal claims — an id and what authorization needs:
const token = jwt.sign(
{ sub: user.id, role: user.role }, // payload (public! no secrets)
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }, // sets 'exp'
)
res.json({ token })
})The verify middleware
export function authenticate(req, res, next) {
const header = req.headers.authorization || ''
const [scheme, token] = header.split(' ')
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Missing or malformed token' })
}
try {
// Pin the algorithm — never trust the token's own 'alg' header:
const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] })
req.user = { id: payload.sub, role: payload.role } // attach identity
next()
} catch (err) {
// Expired vs invalid — return 401 either way, don't leak specifics:
return res.status(401).json({ error: 'Invalid or expired token' })
}
}
// Use it on protected routes:
app.get('/me', authenticate, (req, res) => res.json({ id: req.user.id }))The Bearer scheme
Client sends the token in the Authorization header: GET /me HTTP/1.1 Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0Mi... "Bearer" = whoever bears (holds) this token is treated as the user. That's exactly why it must travel over HTTPS and expire quickly.
Decode vs verify — a critical difference
jwt.decode(token) // ❌ reads claims WITHOUT checking the signature — never trust this
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) // ✅ checks signature + expImplementation checklist
Secret from env, long and random; rotate if leaked.
Pin
algorithmson everyverifycall.Short
expiresInon access tokens; pair with refresh tokens.Wrap
verifyin try/catch →401; never 500 on a bad token.HTTPS only, token in the
Authorizationheader, never in URLs or logs.Minimal payload — id + authorization claims, nothing sensitive.
Combine with RBAC — authentication via JWT, authorization via role checks.