UnlockOS Developers
← Back to blog
🔐

Fail-Closed by Construction: Guards, Bindings, and Tokens

Aug 24, 2026Aug 30, 2026
9 min
227 commits
Depth 8/10
securitytypescriptreliabilitytestingauthorization

Fail-Closed by Construction: Guards, Bindings, and Tokens

Introduction

When a system opens physical doors, the interesting question is never "does it work on the happy path?" It is "what happens when a value is missing, a token is dead, a payment succeeded but issuance didn't, or a test leaked state into the next file?"

A recent block of work on our access-control platform was almost entirely about that second question. This article distills the generalizable engineering patterns from it: how to write guards that cannot silently fail open, how to bind identities to proof instead of to user input, how to manage third-party token lifecycles without inventing false health signals, and how to keep tests from lying to you.


1. Fail-Closed Guards: Replace String Inspection with a Truth Table

A recurring anti-pattern in access checks is deriving a security decision from the shape of a string.

// ANTI-PATTERN: the decision depends on parsing, and parsing has a default branch.
function canEnter(state: string): boolean {
  if (state.includes("verified")) return true;
  if (state.startsWith("guest_")) return true;
  return false; // looks fail-closed... until a new state name contains "verified"
}

The problem is not that this code is wrong today. It is that the decision surface is open: any future string can accidentally satisfy a substring test, and the reviewer cannot enumerate the cases. String inspection turns an authorization decision into a text-matching heuristic.

The fix is to make the input space finite and the mapping explicit — a literal truth table.

type IdentitySource = "line" | "email_otp" | "anonymous";
type BindingKind = "synthetic" | "verified" | "none";

interface AccessDecision {
  readonly allow: boolean;
  readonly reason: string;
}

// Every (source, binding) pair is listed. There is no fallthrough branch
// that can accidentally evaluate to `true`.
const ACCESS_TABLE: Record<IdentitySource, Record<BindingKind, AccessDecision>> = {
  line: {
    synthetic: { allow: false, reason: "synthetic_binding_requires_proof" },
    verified: { allow: true, reason: "ok" },
    none: { allow: false, reason: "no_binding" },
  },
  email_otp: {
    synthetic: { allow: false, reason: "synthetic_binding_requires_proof" },
    verified: { allow: true, reason: "ok" },
    none: { allow: false, reason: "no_binding" },
  },
  anonymous: {
    synthetic: { allow: false, reason: "anonymous_never_allowed" },
    verified: { allow: false, reason: "anonymous_never_allowed" },
    none: { allow: false, reason: "anonymous_never_allowed" },
  },
};

export function decideAccess(
  source: IdentitySource,
  binding: BindingKind,
): AccessDecision {
  return ACCESS_TABLE[source][binding];
}

Three properties make this trustworthy:

  1. Exhaustiveness is checked by the compiler. Record<IdentitySource, Record<BindingKind, ...>> fails to compile if a new enum member is added and not handled. A new state cannot ship undecided.
  2. The default is denial, expressed as data. There is no else return true hiding at the bottom of a function.
  3. Every denial carries a machine-readable reason, which makes audit logs and support triage possible without re-deriving the logic.

A test for this is also finite, which is the point:

it("denies every combination that is not explicitly allowed", () => {
  const sources: IdentitySource[] = ["line", "email_otp", "anonymous"];
  const bindings: BindingKind[] = ["synthetic", "verified", "none"];
  const allowed = sources.flatMap((s) =>
    bindings.filter((b) => decideAccess(s, b).allow).map((b) => `${s}:${b}`),
  );
  expect(allowed.sort()).toEqual(["email_otp:verified", "line:verified"]);
});

This assertion is a whitelist snapshot. If someone widens access, the test fails and names exactly which pair was added.


2. Bind Identity to Proof, Not to Input

A subtle privilege-escalation vector in multi-channel systems: a user authenticates through channel A (say, a messaging platform) and then supplies an email address, which the system binds to their account. The email becomes an identity key. Now anyone who can guess an email can inherit its history.

The correction is a rule that should be stated explicitly in every identity system:

An identifier may only be bound to a principal if the system itself observed proof of control, or if a prior trusted event already associated them.

In practice this means two things:

interface BindingRequest {
  principalId: string;
  email: string;
  /** How we learned about this email. */
  provenance: "otp_verified" | "user_typed" | "imported";
}

async function bindEmail(req: BindingRequest): Promise<Result<void, BindError>> {
  if (req.provenance !== "otp_verified") {
    // Arbitrary, user-supplied emails are never bound. Full stop.
    return err({ code: "UNPROVEN_IDENTIFIER" });
  }
  return ok(await persistBinding(req));
}

And for non-synthetic bindings — where an existing real-world relationship is being claimed — require a historical fact that only the legitimate party could have produced:

-- A binding to an existing guest identity requires at least one completed
-- check-in under that identity. Presence of a record is the proof;
-- absence is a denial, not a "probably fine".
SELECT EXISTS (
  SELECT 1
  FROM check_ins c
  WHERE c.guest_email = normalize_email($1)
    AND c.facility_id = $2
    AND c.status = 'completed'
) AS has_prior_relationship;

Note normalize_email() applied at write time, not only at read time. Normalizing on read is a trap: two rows that differ by case or whitespace become two identities, and a lookup that normalizes will match the wrong one depending on which index the planner chooses. Normalize once, at the boundary, and store the canonical form.


3. Authorization Lives in the Backend, Not in the List Query

A common shortcut: a resource is restricted, so it's removed from the listing endpoint. The UI no longer shows it, the ticket is closed.

But listing and acting are different endpoints. Hiding a member-only resource from a catalogue does nothing for a caller who already knows its ID. The rule we enforce:

// Layer 1: the list endpoint filters (UX — don't show what can't be used).
const visiblePlans = plans.filter((p) => p.audience === "public" || viewerIsMember);

// Layer 2: the action endpoint *re-checks* and rejects (security).
export async function createReservation(input: ReserveInput, ctx: Ctx) {
  const plan = await loadPlan(input.planId);
  if (plan.audience === "members_only" && !(await isActiveMember(ctx.userId, plan.facilityId))) {
    return httpError(403, "PLAN_REQUIRES_MEMBERSHIP");
  }
  if (plan.deletedAt !== null) {
    return httpError(410, "PLAN_DELETED");
  }
  return reserve(input, ctx);
}

The same reasoning applies to soft deletes. A soft-deleted row still exists and is still reachable by ID; every write path must treat deleted_at IS NOT NULL as a hard rejection, and every read path that feeds a selector must exclude it. Soft delete is a state, and states need to be handled in the transition function, not just in the query that populates a dropdown.


4. Invariants Must Hold After the Money Moves

One of the most instructive bugs in this batch: a purchase limit was enforced before payment, but not after. The window between "limit checked" and "entitlement issued" is a classic TOCTOU gap — concurrent requests, retried webhooks, and browser back-buttons all live there.

The general pattern is: check twice, and let the database, not the application, own the invariant.

-- The invariant is expressed where concurrency is actually resolved.
ALTER TABLE ticket_book_purchases
  ADD CONSTRAINT ticket_book_purchase_unique_per_cycle
  UNIQUE (user_id, ticket_book_id, billing_cycle);
async function onPaymentSucceeded(event: PaymentEvent) {
  const withinLimit = await countPurchases(event.userId, event.bookId) < LIMIT;
  if (!withinLimit) {
    // Do NOT issue. Refund/flag instead, and never tell the guest
    // "your tickets are ready" when nothing was issued.
    await markForRefund(event.paymentId, "purchase_limit_exceeded");
    return;
  }
  try {
    await issueEntitlement(event);
  } catch (e) {
    if (isUniqueViolation(e)) return; // idempotent replay, already issued
    throw e;
  }
}

The second half of that bug matters just as much: when issuance fails, do not render a success message. A guest told "your pass is active" who then finds a locked door has lost confidence in the whole platform. The UI copy must be derived from the persisted entitlement, not from the fact that a payment request returned 200.

A closely related invariant is quote binding. If a price is shown to a user, the total must be frozen against the quote:

interface BoundQuote {
  quoteId: string;
  total: number;
  currency: string;
  computedAt: string;
  expiresAt: string;
  inputsHash: string; // hash of plan/rate versions used
}

function confirm(quote: BoundQuote, live: PricingInputs) {
  if (hashInputs(live) !== quote.inputsHash) return httpError(409, "QUOTE_STALE");
  if (Date.now() > Date.parse(quote.expiresAt)) return httpError(409, "QUOTE_EXPIRED");
  return chargeExactly(quote.total, quote.currency);
}

A rate edit by an administrator must never be able to move a total that a guest already saw. Making the quote an explicit, hashed, expiring object turns "the price changed under them" from an untraceable incident into a 409 with a reason code.

And on the pricing domain itself: a disabled rate is not zero. Treating a disabled configuration as 0 silently sells things for free. undefined and 0 must be distinct types all the way down:

type Rate = { kind: "set"; amount: number } | { kind: "unset" };

function resolveRate(r: Rate): number {
  if (r.kind === "unset") throw new PricingError("RATE_NOT_CONFIGURED");
  return r.amount;
}

5. Token Lifecycle: Measure the Real Signal

Integrations with vendor APIs die in a specific way: the refresh token stops working, and the system keeps calling with an access token that will expire in 30 minutes. If you detect "dead token" by inferring from downstream 401s, you get noise; if you infer from token age, you get false alarms.

The correct signal is the one closest to the failure: the outcome of the last refresh attempt.

ALTER TABLE integration_credentials
  ADD COLUMN last_refresh_at timestamptz,
  ADD COLUMN last_refresh_error text,        -- NULL means the last refresh succeeded
  ADD COLUMN consecutive_refresh_failures int NOT NULL DEFAULT 0;

CREATE INDEX ON integration_credentials (consecutive_refresh_failures)
  WHERE last_refresh_error IS NOT NULL;
function isDead(cred: Credential): boolean {
  // Not "the token looks old" and not "some call returned 401":
  // the refresh itself failed, repeatedly.
  return cred.lastRefreshError !== null && cred.consecutiveRefreshFailures >= 2;
}

Two companion patterns make this robust:

Single-flight refresh. Under load, N concurrent callers all notice expiry and all refresh. With rotating refresh tokens, the second rotation invalidates the first, and the integration destroys itself. Collapse refreshes into one in-flight promise per credential, and extend that guarantee to every caller — background cron, webhooks, and interactive paths alike. A single-flight lock that only covers one of three entry points is not a lock.

const inflight = new Map<string, Promise<Token>>();

export function refreshSingleFlight(id: string, fn: () => Promise<Token>): Promise<Token> {
  const existing = inflight.get(id);
  if (existing) return existing;
  const p = fn().finally(() => inflight.delete(id));
  inflight.set(id, p);
  return p;
}

For multi-instance deployments, back this with an advisory lock so the guarantee survives horizontal scaling:

SELECT pg_try_advisory_xact_lock(hashtext('token_refresh:' || $1));

Digest alerts, not per-event alerts. A dead credential can generate thousands of failures per hour. If each one pages, the on-call engineer mutes the channel, and the next real incident is invisible. Aggregating into a single digest per credential per window keeps the signal-to-noise ratio survivable — alert fatigue is a security failure mode, not an ergonomics complaint.


6. Silence Is the Worst Failure Mode

Several changes in this batch share one theme: a failure that used to be swallowed now produces a visible, routed event.

  • Key-delivery failures previously terminated in a catch block. Now they surface as operational incidents.
  • When configuration blocks a send (a channel disabled, credentials absent), the platform operator is paged — because a blocked notification is indistinguishable, from the guest's perspective, from a broken lock.
  • A form-persistence failure during signup is now reported to the user rather than leaving them on a page that looks successful.

The generalizable rule:

type DeliveryOutcome =
  | { status: "sent"; messageId: string }
  | { status: "suppressed"; reason: "channel_disabled" | "quiet_hours" }
  | { status: "blocked"; reason: "not_configured" | "missing_credentials" }
  | { status: "failed"; reason: string; retryable: boolean };

async function deliver(msg: Message): Promise<DeliveryOutcome> {
  const outcome = await transport.send(msg);
  await auditLog.record({ messageId: msg.id, outcome, at: new Date().toISOString() });
  if (outcome.status === "blocked") await pageOperator(msg, outcome.reason);
  return outcome;
}

suppressed and blocked are deliberately distinct. Suppression is an intended policy outcome. Blocking is a misconfiguration that a human must fix. Collapsing them into one "not sent" bucket is how a facility goes a week without delivering entry keys while the dashboard stays green.

The same discipline applies to transports themselves: an email transport should never throw into the caller's control flow. It returns an outcome. Throwing from a notification layer means an unrelated business transaction rolls back because a message could not be sent.


7. Tests That Cannot Lie

Two test-hygiene fixes here are worth generalizing.

Test environments must not leak between files. A shared test-env module that installs a synthetic backend URL into process.env at import time will bleed into every subsequently loaded test file. The result is a suite that passes in one order and fails in another, and — much worse — a suite where a test that should have hit a guard silently hit a stub.

// Explicit lifecycle instead of import-time side effects.
export function withTestEnv(overrides: Record<string, string>) {
  const saved = new Map<string, string | undefined>();
  beforeEach(() => {
    for (const [k, v] of Object.entries(overrides)) {
      saved.set(k, process.env[k]);
      process.env[k] = v;
    }
  });
  afterEach(() => {
    for (const [k, v] of saved) {
      if (v === undefined) delete process.env[k];
      else process.env[k] = v;
    }
    saved.clear();
  });
}

Also resolve fixture paths from import.meta.url, never from process.cwd() — a test that depends on the working directory passes locally and fails in CI for reasons unrelated to the code under test.

Name-only tests are negative value. A test called it("rejects unauthorized access") that only asserts a function returned something is worse than no test: it makes a coverage report claim a guarantee that does not exist. A review pass in this batch specifically went back and made such tests assert real behavior.

// Before: passes even if the guard is deleted.
it("rejects unauthorized access", async () => {
  const res = await handler(req);
  expect(res).toBeDefined();
});

// After: fails the moment the guard weakens.
it("rejects unauthorized access", async () => {
  const res = await handler(reqWithoutMembership);
  expect(res.status).toBe(403);
  expect(await res.json()).toMatchObject({ code: "PLAN_REQUIRES_MEMBERSHIP" });
  expect(await countReservations()).toBe(0); // no side effect occurred
});

The last assertion is the one that matters most in an access-control system: verify that the side effect did not happen, not merely that an error was returned.


8. Guard the Developer Environment Too

A smaller but telling change: a pre-commit hook that blocked one form of a secret-management command was widened to block the command outright. Partial guards on secret handling are security theatre — an attacker or a hurried developer simply uses the other flag.

#!/usr/bin/env bash
set -euo pipefail
# Block the capability, not one spelling of it.
if git diff --cached --name-only | grep -qE '(^|/)\.env($|\.)'; then
  echo "refusing to commit env files" >&2
  exit 1
fi
if grep -rqE 'secrets (set|unset)' <<<"${COMMAND:-}"; then
  echo "secret CLI is not permitted from this workflow" >&2
  exit 1
fi

And hooks themselves must degrade gracefully: a hook installer that fails inside a git worktree and takes the entire dependency install down with it will simply be disabled by the team. A security control that breaks the build gets removed; make it robust or it won't survive contact with reality.


9. Schema Discipline

Two migrations sharing a version number is a silent divergence generator: environments apply them in different orders and end up with different schemas that both claim to be at the same version. Enforce uniqueness in CI:

ls supabase/migrations | cut -d_ -f1 | sort | uniq -d | grep . && {
  echo "duplicate migration version" >&2; exit 1; }

And give operational tables a retention policy. Scheduler and HTTP-queue internals (cron.job_run_details, net._http_response) grow without bound and will eventually take the database down — an availability failure in a system whose job is opening doors.

DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days';
DELETE FROM net._http_response WHERE created < now() - interval '3 days';

Summary

The through-line across all of these changes is the same principle applied at different layers:

Layer Fail-open version Fail-closed version
Guard substring match on a state string exhaustive truth table, compiler-checked
Identity bind user-supplied email bind only proven identifiers
Authorization hide from list re-check and 403 on the action
Limits check before payment check again after, DB constraint as backstop
Pricing undefined coerced to 0 tagged union, explicit error
Quotes recompute at confirm hashed, expiring bound quote
Tokens infer health from downstream errors record last refresh outcome
Notifications swallow failure typed outcome, audit log, page on blocked
Tests assert "defined" assert status, code, and absence of side effect

None of these is clever. That is the point. In a system that controls physical access, trust is built by removing the branches where something can accidentally become true — and by making sure that when something goes wrong, a human finds out before a guest does.

Key Insights

1
Security

Truth tables beat string inspection for access decisions

Deriving authorization from substring or prefix checks leaves an open decision surface. An exhaustive Record-typed mapping makes the compiler enforce that every state is explicitly decided, and denial is the default expressed as data rather than a fallthrough branch.

2
Security

Bind identifiers to proof, never to user input

An email supplied by the user must never become an identity key. Binding requires either system-observed verification (OTP) or a prior trusted event such as a completed check-in, and identifiers must be normalized at write time to avoid duplicate-identity lookups.

3
Authorization

Filtering a list is UX; rejecting the action is security

Removing a restricted resource from a catalogue does nothing against a caller who knows the ID. Every write endpoint must independently re-check membership, audience, and soft-delete state and return an explicit 403/410 with a reason code.

4
Reliability

Invariants must hold after payment, enforced by the database

Checking a purchase limit only before charging leaves a TOCTOU window that concurrency and webhook retries will find. Re-check post-payment, back it with a unique constraint, treat unique violations as idempotent replays, and never show a success message unless the entitlement actually persisted.

5
Reliability

Detect integration death from the refresh outcome, not from inference

Recording last_refresh_error and consecutive failure counts gives a precise health signal, while single-flight refresh (extended to cron, webhook and interactive paths alike, backed by an advisory lock) prevents concurrent rotations from destroying the credential.

6
Observability

Distinguish suppressed from blocked delivery outcomes

A typed DeliveryOutcome separates intended policy suppression from misconfiguration. Blocked sends page an operator instead of being swallowed, and transports return outcomes rather than throwing so a notification failure cannot roll back a business transaction.

7
Testing

Tests must assert absence of side effects, not just error presence

A test that only checks a response is defined makes a coverage report claim a guarantee that does not exist. Assert the status code, the reason code, and that no rows were written, and keep test environments from leaking through import-time side effects or cwd-relative paths.