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, correct SameSite, __Host- prefix where possible.
  • Tokens: access token in memory, refresh token in an HttpOnly cookie or keychain.
  • Access tokens live 5–15 minutes; refresh tokens rotate with reuse detection.
  • JWT verification pins the algorithm and checks iss, aud and exp.
  • 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

  1. Delete another user's record → 404, not 204.
  2. Log out, then replay the captured credential → 401.
  3. Cross-site POST from another origin → refused.
  4. Tampered token or session id → 401.
  5. Twenty-five wrong passwords → 429.

Example

Example · bash
# 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 429s

When 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.

Example · javascript
// 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.