Designing a Fail-Closed Access Gate in TypeScript

A worker walks up to a turnstile on a construction site. Should it open?

That is a smaller question than it looks, and getting it wrong is more expensive than it looks. Say yes when their OSHA-30 lapsed two days ago, or when their site badge was revoked this morning, and you’ve let an out-of-compliance worker onto a live jobsite — the exact thing a workforce-compliance platform exists to prevent. Say no when you shouldn’t and you’ve stalled a $100M project at the gate.

So the decision has to be right, and — just as important — you have to be able to prove afterward why it was made. This post walks through how I build that: a pure, fail-closed decision function, and an immutable audit trail. The code is TypeScript; the ideas are stack-agnostic.

The gate is a pure function

The single most useful decision I made was to keep the gate free of the world. It doesn’t read a database. It doesn’t call Date.now(). It doesn’t hit the network. It takes everything it needs as arguments and returns a decision:

export function evaluateAccess(input: GateInput): AccessDecision {
  const { worker, site, certifications, now } = input;
  // ...decide...
}

now is a parameter, not a call to the system clock. certifications are passed in, not fetched. That one constraint buys three things:

  1. It’s trivially testable. No mocks, no fake timers, no test database. You hand it a worker with an expired credential and assert DENY. Every edge case is a one-line test.
  2. It runs anywhere. The same function can execute in a Node service, or — because it has no dependencies — in an edge worker sitting right next to the turnstile, where you want the decision even if the network is having a bad day.
  3. It’s honest. When all the inputs are explicit, you can’t accidentally hide a database lookup or a clock read inside the “business logic.” The decision is a function of its inputs and nothing else.
workersiterequired credentialscertificationsheld on filenowa parameterevaluateAccesspure functionAccessDecisionALLOW or DENY with reasonsDatabase, clock,network never
Figure 1. Free of the world. Everything the gate needs arrives as an argument; nothing inside it reads a clock, a database or the network.

Fail closed, always

Here is the rule that everything else serves: when the gate is unsure, it denies.

A gate that opens when it’s confused is worse than no gate, because it looks like enforcement. Every ambiguous case routes to DENY:

That last one matters more than it seems. The function is total: it never throws. A thrown exception is a landmine, because somewhere up the stack there’s a try/catch that logs the error and — if nobody thought hard about it — falls through to opening the door. By making bad input return a DENY with a reason instead of throwing, there is no error path that can accidentally become an open gate.

Check-in requestnow parses?Site on record?Worker on record?Every required credentialvalid right now?ALLOWDENYwith reason codesThrow yesyesyesyesnononono never
Figure 2. Every ambiguous case denies. Bad input returns a DENY with a reason rather than throwing, so no error path can fall through to an open gate.
const nowMs = Date.parse(now);
if (Number.isNaN(nowMs)) {
  return deny([{ code: "INVALID_REQUEST", message: "Evaluation time is missing or invalid." }]);
}
if (!site)   return deny([{ code: "UNKNOWN_SITE",   message: "Site is not registered." }]);
if (!worker) return deny([{ code: "UNKNOWN_WORKER", message: "Worker is not on record." }]);

Every DENY carries a reason

An access decision that’s just a boolean is useless to the human standing at the gate. So the gate checks each of the site’s required credentials and returns a structured reason for each failure:

for (const certType of site.requiredCerts) {
  const held = workerCerts.filter((c) => c.type === certType);
  if (held.length === 0) {
    reasons.push({ code: "MISSING_CERT", certType, message: `Required credential ${certType} is not on file.` });
    continue;
  }
  if (held.some((c) => isValidAt(c, nowMs))) continue; // any valid one is enough
  // none valid — report the most actionable failure (expired > revoked)
}

Two details worth calling out. First, “any valid credential of this type is enough” — a worker who renewed their OSHA-30 has both the old (expired) and new (valid) record, and the new one should let them in. Second, the expiry boundary is deliberate and strict: a credential expiring at 17:00:00 is invalid at exactly 17:00:00. now < expiresAt, not <=. On a safety credential you don’t grant a grace second, and — more importantly — you write that decision down as a test so nobody “fixes” it later.

Now the kiosk can display “OSHA-30 expired on 2026-06-29” instead of a red X, and the worker’s foreman knows exactly what to renew.

The audit trail is the actual product

Here’s the part teams underinvest in until an incident forces them to: the record of what happened is often the real output. After a safety event, an OSHA visit, or an insurance claim, someone will ask “who was on site, and were they cleared?” The answer has to be trustworthy — which means it can’t be something an admin (or an attacker with a database connection) can quietly rewrite.

So every check-in — allowed and denied — is appended to a hash-chained log. Each entry commits to the hash of the entry before it:

const body = { seq, at, workerId, siteId, deviceId, decision, reasons, prevHash };
const hash = createHash("sha256").update(canonical(body)).digest("hex");

Because each row’s hash depends on the previous row’s hash, editing or deleting any historical entry breaks the chain from that point forward. A verify() pass re-derives the chain and reports the first seq that doesn’t match. You can’t flip a past DENY to an ALLOW, and you can’t make a check-in disappear, without it being detectable. (This is the same tamper-evident pattern I used for legal invoice numbering in a compliance product called Sequent — the mechanics transfer directly.)

seq 1hash h1seq 2prevHash h1, hash h2seq 3prevHash h2, hash h3Flip seq 2DENY to ALLOWverify()re-derives the chainFirst mismatchreported at seq 3 edit
Figure 3. The hash chain. Each check-in commits to the hash of the one before it, so editing or deleting any past entry breaks every hash after it.

The log is append-only by construction: there is no update or delete method to call.

What this is not

This is a focused slice, not a platform. Real deployments need Postgres behind the store, actual badge/biometric identity resolution at the device, per-project multi-tenancy and RBAC, an alerting job that emails a subcontractor’s admin at 30/14/7 days before a credential lapses, and a durable, periodically-anchored audit chain. But the shape holds at every size: keep the decision pure, fail closed, explain every denial, and write down what you did in a way you can’t quietly rewrite.

That’s the whole discipline. A boring function and an honest ledger — which is exactly what you want standing between an unqualified worker and a live site.