Defence in Depth and Failing Closed

Every control eventually fails. Layering is what decides whether that is an incident or a log line.

Security controls are not a checklist where each item is either present or absent. They are layers, and the question that matters is: when this layer fails, what is behind it?

Layering, concretely

Take one endpoint that returns an invoice. The layers, outermost first:

  1. Network — TLS, WAF, DDoS protection.
  2. Gateway — token verification, route-level scope, rate limit.
  3. Application — re-verify identity, validate input, check the role.
  4. Data access — the query is scoped by user and tenant.
  5. Database — row-level security, least-privilege credentials.
  6. Data — sensitive columns encrypted; the export path shapes output.
  7. Detection — audit log, anomaly alerting.

A missing role check at layer 3 is survivable if layers 4 and 5 hold. A missing check at every layer is a breach. The point is not that you need all seven — it is that you should know which ones you have.

Fail closed, always

When a control cannot make a decision, the answer is no.

  • Rate limiter's Redis is down → reject or degrade, never let everything through.
  • The authorization service times out → deny.
  • A validation library throws → reject the input.
  • An unknown field arrives → reject the request, do not ignore it.

Failing open converts a dependency outage into an authorization bypass, and it is almost always introduced during an incident by someone trying to restore service.

Deny by default

New routes should be protected until someone marks them public — not public until someone remembers to protect them. Apply authentication to the router, and opt individual routes out explicitly. That way the failure mode of forgetting is a 401 on a public endpoint, which someone reports in minutes, rather than an open private endpoint, which nobody notices for a year.

Make the safe thing the easy thing

Controls that depend on every developer remembering will fail at the fortieth query. A repository that cannot be called without a tenant, a serializer that must be given a field list, a router that authenticates by default — these survive staff turnover in a way that a wiki page does not.

Example

Example · javascript
// ❌ Opt-in security: forgetting means EXPOSED
app.get('/api/public/status', getStatus);
app.get('/api/invoices', auth, listInvoices);
app.get('/api/reports', listReports);          // ← forgot. Now public.

// ✅ Opt-out security: forgetting means BROKEN, which gets reported
const PUBLIC = new Set(['/api/public/status', '/api/health']);

app.use('/api', (req, res, next) => {
  if (PUBLIC.has(req.path)) return next();
  return auth(req, res, next);
});

app.get('/api/reports', listReports);          // ← protected automatically

When to use it

  • A forgotten role check is contained by row-level security in the database, turning a would-be breach into an internal finding.
  • A rate limiter whose Redis is unavailable rejects with 503 rather than letting an attack through unthrottled.
  • A new endpoint merged without an auth decorator returns 401 in staging, because the router authenticates by default.

More examples

Failing closed in three common places

The local in-process fallback is worth the twenty lines: it keeps a limit in place during a Redis outage without taking the whole API down.

Example · javascript
// 1. RATE LIMITER — the classic fail-open
async function rateLimit(req, res, next) {
  try {
    const count = await redis.incr(`rl:${req.ip}`);
    if (count === 1) await redis.expire(`rl:${req.ip}`, 60);
    if (count > LIMIT) return res.status(429).json({ error: 'rate_limited' });
    return next();
  } catch (err) {
    // ❌ return next();   ← "Redis is down, let traffic through"
    //    An attacker who can degrade Redis has removed your rate limiting.

    // ✅ Degrade to a conservative in-process limit rather than to nothing.
    metrics.increment('ratelimit.store_error');
    if (!localFallback.allow(req.ip)) {
      return res.status(429).json({ error: 'rate_limited' });
    }
    logger.error({ err }, 'rate limit store unavailable — local fallback');
    return next();
  }
}

// 2. AUTHORIZATION SERVICE — no ambiguity here at all
async function authorize(req, res, next) {
  let decision;
  try {
    decision = await policyService.check({
      subject: req.user.id, action: req.method, resource: req.path,
    });
  } catch (err) {
    // ❌ allow on error — a timeout becomes a full bypass
    metrics.increment('authz.service_error');
    return res.status(503).json({ error: 'service_unavailable' });
  }
  return decision.allow ? next() : res.status(403).json({ error: 'forbidden' });
}

// 3. VALIDATION — reject unknown fields rather than dropping them
const schema = z.object({
  name: z.string().max(100),
  email: z.string().email(),
}).strict();          // ← .strict() ERRORS on unknown keys
// Without it, {"name":"a","role":"admin"} silently passes validation and the
// only thing standing between you and privilege escalation is the ORM.

One request, seven layers

Walking a real request through the layers and then removing them one at a time is the clearest way to justify each control's existence to a sceptical reviewer.

Example · javascript
// GET /api/tenants/acme/invoices/1043 — trace every control it passes.

// L1 NETWORK   TLS 1.3 · WAF drops obvious payloads · DDoS scrubbing
// L2 GATEWAY
app.use(verifyJwt({ algorithms: ['RS256'], issuer: ISS, audience: AUD }));
app.use(rateLimit({ limit: 1000, windowMs: 60_000 }));
app.use(stripClientHeaders(['x-internal-', 'x-user-', 'x-tenant-']));

// L3 APPLICATION
app.get('/api/tenants/:tenant/invoices/:id',
  internalAuth,                                  // re-verify, do not trust L2
  requireScope('invoices:read'),                 // route-level
  async (req, res) => {
    // L4 DATA ACCESS — tenant from the CREDENTIAL, not the path
    if (req.user.tenant !== req.params.tenant) {
      return res.status(404).json({ error: 'not_found' });
    }

    // L5 DATABASE — RLS enforces tenant isolation even if L4 were wrong
    const invoice = await withTenant(req.user.tenant, (tx) =>
      tx('invoices').where({ id: req.params.id }).first());
    if (!invoice) return res.status(404).json({ error: 'not_found' });

    // L6 DATA — explicit shape; internal columns cannot escape
    const body = InvoiceSerializer.public(invoice);

    // L7 DETECTION — every read of a financial record is audited
    await audit.record({
      event: 'invoice.read', actor: req.user.id, tenant: req.user.tenant,
      target: invoice.id, ip: req.ip, requestId: req.requestId,
    });

    res.json(body);
  });

// Now ask the question that matters:
//   Delete the L4 check → L5 still blocks it. Contained.
//   Delete L4 AND L5    → L7 records it. Detected, not prevented.
//   Delete L4, L5, L7   → cross-tenant read, silently, indefinitely.
//
// You do not need all seven everywhere. You need to KNOW which you have.

Making the safe path the only path

The TypeScript approach is the strongest of the four: an unauthenticated route becomes a build failure rather than something a reviewer has to notice.

Example · javascript
// Controls that rely on memory fail at the fortieth query. Encode them in types
// and constructors so the unsafe version does not compile or does not exist.

// ❌ Relies on every developer remembering
const invoices = await db('invoices').where({ id });

// ✅ The repository cannot be constructed without a tenant
class InvoiceRepository {
  #tenantId;
  constructor(tenantId) {
    if (!tenantId) throw new Error('InvoiceRepository requires a tenantId');
    this.#tenantId = tenantId;
  }
  find(id)  { return db('invoices').where({ id, tenant_id: this.#tenantId }).first(); }
  list(q={}){ return db('invoices').where({ ...q, tenant_id: this.#tenantId }); }
  // There is no method that omits the tenant. The unsafe query is unwritable.
}

// ✅ The serializer cannot be called without a field list
class Serializer {
  static shape(row, fields) {
    if (!Array.isArray(fields) || !fields.length) {
      throw new Error('Serializer.shape requires an explicit field list');
    }
    return Object.fromEntries(fields.filter((f) => f in row).map((f) => [f, row[f]]));
  }
}
// res.json(row) is now visibly different from res.json(Serializer.shape(row, [...]))
// in review, which is the point.

// ✅ TypeScript makes it a compile error
type Authenticated<T> = T & { user: { id: string; tenant: string } };
function handler(req: Authenticated<Request>, res: Response) { /* ... */ }
// A route registered without the auth middleware fails to type-check, because
// the plain Request lacks `user`.

// ✅ And a lint rule for the rest
// eslint: no-restricted-syntax
//   "CallExpression[callee.property.name='json'][arguments.0.type='Identifier']"
//   → "Do not serialise a model directly; use Serializer.shape()."

Discussion

  • Be the first to comment on this lesson.