Production Checklist
Everything in this course, condensed into a list you can run down before you ship.
One page, four sections. Nothing here is new — it is the whole course as a set of yes/no questions.
Transport
- HTTPS everywhere, with HSTS.
- HTTP redirects to HTTPS, and trusted proxies configured so the app knows it.
- Certificates renew automatically, with expiry alerting.
Authentication
- Passwords hashed with Argon2id, scrypt or bcrypt — never a fast hash.
- Minimum 12 characters; breached-password check on signup and change.
- Identical response and comparable timing for unknown-user and wrong-password.
- Rate limits per IP and per account, with backoff rather than lockout.
- MFA available; enforced for admins.
- Session id regenerated at login, after MFA, and on privilege elevation.
- Idle and absolute timeouts on every session.
Credential handling
- Cookies:
HttpOnly,Secure, correctSameSite,__Host-prefix where possible. - Tokens: access token in memory, refresh token in an
HttpOnlycookie or keychain. - Access tokens live 5–15 minutes; refresh tokens rotate with reuse detection.
- JWT verification pins the algorithm and checks
iss,audandexp. - Logout destroys server-side state, not only the cookie.
- Password change revokes every session and token.
Authorization and the rest
- Ownership checked inside the query, on every resource route.
- Scope and role both checked where both apply.
- 401 for unauthenticated, 403 for forbidden — not interchangeable.
- CORS: exact origins, no wildcard with credentials,
Vary: Origin. - CSRF protection on every cookie-authenticated state change.
- No credentials in URLs, logs, or error reports.
- Secrets from a store, validated at boot, rotatable without downtime.
- Auth events logged: logins, failures, resets, MFA changes, token revocations.
Test these five before shipping
- Delete another user's record →
404, not204. - Log out, then replay the captured credential →
401. - Cross-site POST from another origin → refused.
- Tampered token or session id →
401. - Twenty-five wrong passwords →
429.
Example
# The five-minute pre-ship test. Any surprise here is a blocker.
# 1. Another user's record must not be deletable
curl -X DELETE https://dfg.com/api/notes/$OTHER_USERS_NOTE \
-H "Authorization: Bearer $MY_TOKEN" -o /dev/null -w '%{http_code}\n'
# expect 404
# 2. A revoked credential must stop working
curl -X POST https://dfg.com/auth/logout -H "Authorization: Bearer $TOKEN"
curl https://dfg.com/api/notes -H "Authorization: Bearer $TOKEN" \
-o /dev/null -w '%{http_code}\n'
# expect 401
# 3. A foreign origin must not be allowed with credentials
curl -i https://dfg.com/api/notes -H "Origin: https://evil.com" \
| grep -i 'access-control-allow-origin'
# expect: nothing
# 4. A tampered token must fail
curl https://dfg.com/api/notes -H "Authorization: Bearer ${TOKEN}x" \
-o /dev/null -w '%{http_code}\n'
# expect 401
# 5. Brute force must be throttled
for i in $(seq 1 25); do
curl -s -o /dev/null -w '%{http_code} ' -X POST https://dfg.com/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"wrong"}'
done; echo
# expect 401s turning into 429sWhen to use it
- A team runs the five curl checks before every release and catches a missing ownership check introduced by a refactor.
- A pre-launch review finds access tokens with a 30-day lifetime and shortens them to ten minutes with refresh rotation.
- An auditor is given this checklist with evidence for each line, turning a week-long review into a day.
More examples
The checks as automated tests
The alg=none and wrong-audience tests take two minutes to write and permanently close the two JWT mistakes that reappear whenever verification code is touched.
// Put these in CI. They are the assertions that catch regressions people
// reintroduce during refactors, in every codebase.
describe('security regressions', () => {
it('never returns another user\'s data', async () => {
const res = await request(app).get('/api/notes')
.set('Authorization', `Bearer ${aliceToken}`);
expect(res.body.notes.every((n) => n.userId === alice.id)).toBe(true);
});
it('returns 404 for another user\'s record', async () => {
const res = await request(app).delete(`/api/notes/${bobNote.id}`)
.set('Authorization', `Bearer ${aliceToken}`);
expect(res.status).toBe(404);
});
it('rejects a token with the wrong audience', async () => {
const wrong = jwt.sign({ sub: alice.id }, ACCESS_SECRET,
{ algorithm: 'HS256', issuer: ISSUER, audience: 'https://other.example/api' });
expect((await request(app).get('/api/notes')
.set('Authorization', `Bearer ${wrong}`)).status).toBe(401);
});
it('rejects alg=none', async () => {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: alice.id })).toString('base64url');
expect((await request(app).get('/api/notes')
.set('Authorization', `Bearer ${header}.${payload}.`)).status).toBe(401);
});
it('does not send CORS headers to a foreign origin', async () => {
const res = await request(app).get('/api/notes').set('Origin', 'https://evil.com');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
it('never sets a cookie without HttpOnly and Secure', async () => {
const res = await request(app).post('/auth/login').send(validCredentials);
for (const cookie of res.headers['set-cookie'] ?? []) {
if (cookie.startsWith('XSRF-TOKEN')) continue; // readable by design
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/Secure/i);
}
});
it('throttles repeated failures', async () => {
const codes = [];
for (let i = 0; i < 25; i++) {
codes.push((await request(app).post('/auth/login')
.send({ email: alice.email, password: 'wrong' })).status);
}
expect(codes).toContain(429);
});
});What to log, and what never to log
Logging a truncated hash of the session id gives you correlation across log lines without ever writing a usable credential to disk.
// A security audit trail answers "what happened?" months later. Make it boring
// and complete, and keep credentials out of it entirely.
const AUTH_EVENTS = [
'login.success', 'login.failure', 'logout',
'password.changed', 'password.reset_requested', 'password.reset_completed',
'mfa.enabled', 'mfa.disabled', 'mfa.failure',
'token.issued', 'token.revoked', 'token.reuse_detected',
'session.revoked_all', 'permission.denied',
];
export async function auditAuth(event, { userId, req, meta = {} }) {
await db.auditLog.insert({
event,
userId: userId ?? null,
ip: req?.ip ?? null,
userAgent: req?.get('user-agent') ?? null,
requestId: req?.get('x-request-id') ?? null,
// ✅ metadata about the credential — never the credential
meta: {
...meta,
tokenId: meta.jti ?? undefined, // the id, not the token
sessionIdHash: meta.sid ? sha256(meta.sid).slice(0, 16) : undefined,
},
at: new Date(),
});
// Alert on the events that mean something is wrong right now
if (['token.reuse_detected', 'mfa.disabled'].includes(event)) {
await alertSecurityTeam(event, userId, req?.ip);
}
}
// ❌ Never in a log line:
// passwords, session ids, access or refresh tokens, API keys,
// Authorization or Cookie headers, TOTP secrets, recovery codes,
// reset tokens, or full request bodies from auth endpoints.
Discussion