Now that your API can create and return data, the next question is unavoidable: who is allowed to do this? Authentication proves identity — “you are Aki.” Authorization decides permission — “Aki may delete this post, but not that one.”
Get this wrong and everything else is decoration. Get it right and users can trust your app with real data.
Never store passwords in plain text
A password is a secret you confirm, never a value you keep. Storing it as typed means one database leak exposes every account — and people reuse passwords everywhere.
Passwords are stored as hashes: the result of a one-way function that cannot practically be reversed. Two properties matter:
- Salting — a unique random value is mixed in, so identical passwords produce different hashes and precomputed tables (rainbow tables) fail.
- Slowness — a good algorithm is deliberately expensive, so brute-forcing millions of guesses is impractical.
bcrypt is the classic choice; argon2id is the modern recommendation when available:
import bcrypt from 'bcrypt'
const password = 'correct-horse-battery'
const hash = await bcrypt.hash(password, 12)
// '$2b$12$s2K...' — store ONLY this
const ok = await bcrypt.compare(password, hash) // true
const bad = await bcrypt.compare('wrong', hash) // falseThe cost factor (12) is the work multiplier. Higher means slower hashing and harder brute force. Values of 10–12 are typical; benchmark on your server and pick what takes roughly 100–300ms.
Register and login, end to end
The examples here use an in-memory array so the lesson stays database-agnostic — the next module swaps in MongoDB with Mongoose, and the auth logic does not change.
import express from 'express'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
const app = express()
app.use(express.json())
const users = []
const SECRET = process.env.JWT_SECRET
// Register
app.post('/api/v1/auth/register', async (req, res) => {
const { email, password } = req.body ?? {}
if (typeof email !== 'string' || typeof password !== 'string' || password.length < 8) {
return res.status(422).json({
error: { code: 'VALIDATION_ERROR', message: 'Valid email and 8+ character password required' },
})
}
const normalized = email.trim().toLowerCase()
if (users.some((u) => u.email === normalized)) {
return res.status(409).json({
error: { code: 'EMAIL_IN_USE', message: 'An account with this email already exists' },
})
}
const passwordHash = await bcrypt.hash(password, 12)
const user = { id: users.length + 1, email: normalized, passwordHash, role: 'user' }
users.push(user)
const { passwordHash: _hidden, ...safeUser } = user
res.status(201).json({ data: safeUser })
})
// Login
app.post('/api/v1/auth/login', async (req, res) => {
const { email, password } = req.body ?? {}
const normalized = String(email ?? '').trim().toLowerCase()
const user = users.find((u) => u.email === normalized)
const valid = user ? await bcrypt.compare(String(password ?? ''), user.passwordHash) : false
if (!valid) {
// Same message for unknown email and wrong password: no user enumeration.
return res.status(401).json({
error: { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' },
})
}
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: '1h' })
res.json({ data: { token } })
})Two details that are easy to miss:
- Normalize email (trim, lowercase) before storing and comparing, or
Aki@example.comandaki@example.combecome two accounts. - Return one generic login error. Saying “no account with that email” tells attackers which emails are registered.
What a JWT actually is
A JSON Web Token has three base64url-encoded parts separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInJvbGUiOiJ1c2VyIn0.X3f9...- Header — algorithm and type.
- Payload — claims: who (
sub), role, expiry (exp). Readable by anyone — base64 is encoding, not encryption. - Signature — proves the token was issued by someone holding the secret and was not modified.
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: '15m' })
const payload = jwt.verify(token, SECRET) // throws if tampered with or expiredNever put secrets, personal data, or password hashes in the payload. Keep access tokens short-lived (minutes) and pair them with a longer-lived refresh token that can be revoked — a stolen access token is valid until it expires, because JWTs are stateless.
Sessions vs tokens
| Server sessions | JWT | |
|---|---|---|
| State lives | Server (memory or database) | Inside the token |
| Revoke instantly | Yes | Only at expiry, unless you keep a denylist |
| Scales across servers | Needs shared session store | Stateless, no store needed |
| Typical storage | httpOnly cookie | Cookie or Authorization header |
Both are legitimate. A first-party web app with the same backend can use session cookies and keep revocation simple. An API serving web, mobile, and third parties benefits from stateless JWTs.
Protect routes with middleware
function requireAuth(req, res, next) {
const header = req.get('authorization') ?? ''
const token = header.startsWith('Bearer ') ? header.slice(7) : null
if (!token) {
return res.status(401).json({
error: { code: 'UNAUTHENTICATED', message: 'Missing access token' },
})
}
try {
req.user = jwt.verify(token, SECRET)
next()
} catch {
return res.status(401).json({
error: { code: 'INVALID_TOKEN', message: 'Invalid or expired token' },
})
}
}
app.get('/api/v1/auth/me', requireAuth, (req, res) => {
res.json({ data: { id: req.user.sub, role: req.user.role } })
})Authorization: roles and ownership
Authentication answers who; authorization answers whether. Two checks cover most apps.
Role-based access control (RBAC) — gate whole endpoints:
const requireRole = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: 'You do not have permission for this action' },
})
}
next()
}
app.delete('/api/v1/users/:id', requireAuth, requireRole('admin'), (req, res) => {
res.status(204).end()
})Ownership checks — gate individual resources:
app.patch('/api/v1/posts/:id', requireAuth, async (req, res) => {
const post = await findPost(req.params.id)
if (!post) return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Post not found' } })
if (post.authorId !== req.user.sub) {
return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Not your post' } })
}
// ... update and return
})401 means “I do not know who you are.” 403 means “I know who you are, and the answer is no.” Mixing them up makes debugging auth issues miserable.
Harden the edges
Authentication code is a target. These five lines remove whole categories of attacks:
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
app.use(helmet()) // sensible security headers
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }))
app.use(
'/api/v1/auth',
rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 }) // 20 attempts / 15 min / IP
)Checklist before you ship auth:
- Secrets (
JWT_SECRET, API keys) live in.env, are long and random, and never appear in Git. - Login and register routes are rate-limited; repeated failures get slower or locked.
- Cookies are
httpOnly,Secure, andSameSite. - Password reset and email verification tokens are single-use and expire quickly.
- Server logs never contain passwords, tokens, or full request bodies on auth routes.
npm auditis clean of known vulnerabilities in auth dependencies.
Try it
Run npm install bcrypt in a scratch folder, save this as auth.js, and run it with node.
import bcrypt from 'bcrypt'
const password = 'correct-horse-battery'
const hash = await bcrypt.hash(password, 12)
console.log('stored hash:', hash)
console.log('correct password:', await bcrypt.compare(password, hash))
console.log('wrong password:', await bcrypt.compare('hunter2', hash))Check your understanding
Why is bcrypt preferred over a fast hash like SHA-256 for passwords?
A logged-in user tries to delete someone else’s post and is refused. Which status code is correct?
Build a complete auth API
Create auth-lab and implement authentication with bcrypt, JWT, and Express. In-memory users are fine.
npm init -y
npm pkg set type=module
npm install express bcrypt jsonwebtoken helmet cors express-rate-limit- Put
JWT_SECRETin.env(generate it withopenssl rand -base64 32), add.envto.gitignore, and load it withnode --env-file=.env. - Implement the routes from this lesson:
POST /api/v1/auth/register→201with the user minus the hash;409for a duplicate email;422for invalid input.POST /api/v1/auth/login→200with{ data: { token } };401with one generic message for any failure.GET /api/v1/auth/me→ requires a valid bearer token.GET /api/v1/admin/users→requireAuthplusrequireRole('admin'); a normal user gets403.
- Add
helmet(), CORS restricted toCLIENT_URL, and rate limiting on the auth routes. - Test the whole flow with curl: register, login, call
/mewith the token, call/mewith a tampered token (expect401), call the admin route as a normal user (expect403). - Verify in your responses that the password hash never appears.
- Write a single
requests.httpfile documenting the flow for the next person.
Summary
- Store passwords only as salted, slow hashes (bcrypt, argon2id); never encrypt or store them recoverably.
- Normalize emails and return one generic login error to prevent user enumeration.
- JWTs are signed, not secret — keep sensitive data out of the payload and expiry short.
requireAuthverifies the token and attachesreq.user;requireRoleand ownership checks handle authorization.- 401 means unauthenticated; 403 means authenticated but not allowed.
- Sessions and JWTs are both valid; prefer
httpOnlycookies overlocalStorage. - Helmet, CORS, rate limiting, and secret hygiene are part of auth, not extras.
Next: proving all of it works — testing and centralized error handling, so regressions are caught before your users find them.