Refuse by default
Opening the pre-launch security audit with fail-closed error discipline, the rule that every access check refuses when it cannot prove the request is allowed.
The app works: sign-in, the paginated invoices list, the webhook that books a subscription, the wizard that saves its draft. Before launch you go back to the seams, the places where one part of the system trusts another, and ask not whether they work on the happy path but whether they fail in the right direction when they break.
This pass is about one direction. Every gate that decides who gets in, an authorization check, a tenancy filter, a paywall, a webhook signature verify, has to refuse by default. The hard case is when the check itself breaks: the database drops the connection mid-query, or a row the types swore could never be null comes back null. A broken check has to count as a refusal, never a wave-through. That rule is called fail closed, and it is the commitment behind this chapter.
You have been shipping it for chapters already without naming it: requireOrgUser at the top of every page, the authedAction wrapper your mutations pass through, the signature verify on the Stripe webhook, the tenantDb(orgId) that scopes every query. This lesson names the rule, shows the shape that makes it hold, and hands you the one question to ask of any gate in the codebase: if this check throws, does the user get the resource, or get refused?
When an access check throws: the fork
Section titled “When an access check throws: the fork”Here is a Server Action that deletes a customer. It does the three things a privileged mutation owes: it resolves who is calling, checks they are allowed, then does the work.
'use server';
export const deleteCustomer = async (formData: FormData) => { const { db } = await requireOrgUser(); await requireRole('admin'); const { id } = deleteCustomerSchema.parse(Object.fromEntries(formData));
await db.delete(customers).where(eq(customers.id, id)); return ok(null);};requireRole('admin') is the gate. To answer it reads the session, queries the membership table for this user in this org, and compares the role it finds against 'admin'. That is three reads, and any of them can go wrong in a way you never typed out: Postgres drops the connection, the membership row the schema promises is always there comes back null from a half-applied migration, or the role column holds a string outside the union you thought was exhaustive. In each case requireRole returns neither yes nor no. It throws.
So the membership query throws. What happens to the delete on the next line?
There are exactly two answers, and they sit far apart.
- Fail-open. The exception bubbles past the gate but nothing stopped the action. Execution continues, and if it reaches the delete before the error is swallowed, the delete already ran. The system never learned the caller’s role and proceeded anyway.
- Fail-closed. The exception is read as one thing, this check did not succeed, and the request is refused. The delete never runs. The user gets an error, not the customer’s data.
Same code, same thrown exception, opposite outcome. One permission system opens when it is confused; the other locks. Here is the thesis you will spend the rest of the lesson earning: fail-closed is the default for every access-shaped check in the codebase. When a gate cannot be sure, the answer is no.
The trap is that a gate has a third outcome you rarely picture: not “allowed” and not “denied” but “the check broke.” That blind spot is exactly where fail-open bugs live.
A gate has three outcomes, not two. Fail-closed folds the uncertain third, the check itself broke, into a refusal.
Fail-closed: every doubt is a deny
Section titled “Fail-closed: every doubt is a deny”Fail-closed is the rule that collapses that third outcome into the second: an exception thrown inside the check is treated as a refusal, not as “we don’t know, let it through.”
Concretely, when requireRole throws:
- The action body never runs. The delete, the write, the side effect: none of it happens.
- The user gets a refusal, a 403-shaped response or a generic error page, not the resource.
- The operator gets the original error in the logs, with its full stack and cause chain, so someone can fix the broken query. The error does not vanish.
Fail-open is the precise opposite, and it is always the bug.
The one line to carry out of this lesson is every doubt is a deny. The key word is prove: a gate’s job is to prove the request is allowed, and if it cannot, it refuses. A check that read stale data, or read only half of what it needed, or got null for a column that is never supposed to be null, or hit a code path you never branched for, has proved nothing. Each is a “could not prove,” and fail-closed reads every one as no.
This is the same lesson the type system already taught you about catch. The binding you catch is typed unknown, telling you structurally that you do not know what happened: the check did not run to completion, so you cannot prove anything about the result, so the answer is no. Fail-closed is taking that unknown seriously instead of shrugging at it.
The gates this rule covers
Section titled “The gates this rule covers”You met the rule on requireRole, but it is not a fact about role checks. It is a fact about gates: anything whose job is to decide whether a request may proceed. The codebase has a handful, and one rule covers them all.
- Authorization gates:
requireRole,requireOrgUser, and theauthedActionandauthedRoutewrappers, the membership lookup and role comparison that decide who may act. - Tenancy filters: every query that scopes to
orgId = $1throughtenantDb(orgId), plus visibility predicates likeactive()that decide whose data a query may see. - Paywall and entitlement checks: the plan lookup, feature-flag read, and seat-count compare that decide whether a billing tier may reach a paid feature.
- Signature verification: the constant-time HMAC compare on every incoming webhook, which decides whether a request really came from Stripe.
- Idempotency claims: the
INSERT ... ON CONFLICT DO NOTHINGon theprocessed_eventsledger that decides whether a webhook event is new work or a duplicate to skip. - CSRF and origin checks: Server Actions ship this out of the box, but the rule still applies wherever a hand-written origin check lands.
Each owns a different question, who can act, whose data, which tier, is this really Stripe, have I done this already, but every gate answers to the same one:
If this check throws, does the user get the resource or get refused?
The senior answer is always refuse. Keep that question; it works on any gate, and the rest of this lesson trains you to spot the shapes that answer it wrong.
Sort each check by what it does when something throws *inside* it. A gate that refuses on a broken check fails closed; a gate that proceeds anyway fails open. Drag each item into the bucket it belongs to, then press Check.
requireRole throws on a corrupt membership row; the wrapper catches it and returns a 403.try { await requireRole('admin') } catch { /* log */ }, then the mutation runs.false on a bad signature and on an HMAC library exception.off when Redis is unreachable.orgId === input.orgId || isSuperAdmin(user), and isSuperAdmin can throw.Four fail-open anti-patterns
Section titled “Four fail-open anti-patterns”If you cannot write the bug, you cannot review for it. Here are the canonical ways fail-closed slips, each one named so you can grep your own code for it later. Each looks reasonable in a pull request, and each opens the gate when the check breaks. The first two are worth seeing in code beside their fix.
The log-and-continue empty catch. This is the archetype every other fail-open bug varies on. A developer wraps the authorization check in a try/catch, the check throws, the catch logs it, and execution keeps going.
export const deleteCustomer = async (formData: FormData) => { const { db } = await requireOrgUser(); try { await requireRole('admin'); } catch { logger.warn('role check failed'); }
const { id } = deleteCustomerSchema.parse(Object.fromEntries(formData)); await db.delete(customers).where(eq(customers.id, id)); return ok(null);};The catch swallows the throw and execution falls through to the delete. When requireRole blows up, the catch writes a line nobody reads at 3am, and the code below runs exactly as if the check had passed. The delete fires for a caller whose role was never proven. Logging is not denying.
export const deleteCustomer = authedAction( 'admin', deleteCustomerSchema, async (input, ctx) => { await ctx.db.delete(customers).where(eq(customers.id, input.id)); return ok(null); },);Don’t catch it here. Let it throw. The role check lives in the wrapper now. A too-low role comes back as a returned refusal; a broken check throws clean past the wrapper to the framework’s error boundary, which refuses. Either way nothing in this body runs unless the check passed, and there is no call-site try/catch to get wrong.
The boolean that swallows the throw. This one is subtler, because there is no catch in sight. A requireRole is written to return a boolean, and the call site branches on it. The trap is that false now means two things: “not an admin,” and “the check threw and we defaulted to false.”
const isAdmin = async (): Promise<boolean> => { try { const { role } = await requireOrgUser(); return roleAtLeast(role, 'admin'); } catch { // looks safe — it isn't return false; }};
if (!(await isAdmin())) { return err('forbidden', 'You do not have permission to do this.');}false conflates “no” with “don’t know”. Returning false on exception looks like fail-closed, and at this one call site it is. But the value has lost the difference between a proven “not admin” and a broken check, so the next caller who reads isAdmin() as a plain yes/no inherits a sentinel that lies. The operator never sees the error either, because the catch ate it.
const { user, orgId, role } = await requireOrgUser();
if (!roleAtLeast(role, 'admin')) { return err('forbidden', 'You do not have permission to do this.');}No sentinel: read the role, and let a broken check throw. A too-low role is an expected refusal the wrapper returns; a broken session read is an exception that propagates to the framework boundary, which refuses. “Denied” and “don’t know” stay distinct, one a returned Result and the other a thrown error, so no caller conflates them.
The || carve-out. Picture a tenancy filter written as orgId === input.orgId || isSuperAdmin(user): the row matches the org, or you are a super-admin. It reads fine until isSuperAdmin hits a misconfigured table and throws. Depending on the short-circuit shape, the rejection resolves truthy and the || waves the request through, or the throw escapes into a catch that proceeds. Either way, an exception in the super-admin path becomes an allow. The fix is structural: one filter with no || in the predicate, and the super-admin path as its own code path with its own gate.
The signature verify that returns false on exception. A webhook handler calls a verify function that returns false for a bad signature, and also returns false when the HMAC library throws on a malformed header. Now the handler cannot tell “this request is forged” from “my own crypto code broke,” and a library bug quietly turns into “we stopped verifying signatures.” The fix splits the two: a real mismatch returns a 401, and an exception throws, which the framework turns into a 500 so the provider retries.
The structural shape that makes refusal the only path
Section titled “The structural shape that makes refusal the only path”If your model of fail-closed is “remember to wrap every access check in a try/catch that denies,” you have already lost. That is discipline you maintain by hand at every call site, and the bugs above are what that discipline looks like when it slips. The senior move is to stop relying on memory: fail-closed becomes a property of two seams, written once.
- The check throws on its own failure.
requireRole(min): RoleandrequireOrgUser(): { user, orgId, role }throw on failure; they never return a “we don’t know” sentinel. A caller holding the return value has thereby learned the check passed. There is no third value to misread. - One wrapper turns every failure into a refusal.
authedActionruns the checks. An expected refusal (too-low role, bad payload) it returns as theResultfailure branch ({ ok: false, error: { code: 'forbidden', ... } }). An unexpected throw (the membership read hitting a dead connection) it does not catch at all; the exception flies past to the framework’serror.tsx, which refuses. A clean signed-out state is not a failure:requireOrgUserredirects, and that exit also flies past. The body you write, the mutation, never sees atryand never writes a refusal. Both outcomes are decided above it.
That is the point. To write a fail-open action you would have to reach past the wrapper on purpose: open-code the check in your body, catch it yourself, and proceed anyway. The wrong answer is no longer the easy one. And every fail-closed decision in the action layer now lives in one place to lint: a single function body, so an auditor reads one file instead of grepping every call site.
You built this wrapper a few chapters back. Read it again through the fail-closed lens, gate by gate, watching where a broken check lands.
export const authedAction = <Schema extends z.ZodType, TOut>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Result<TOut>>, ) => async (formData: FormData): Promise<Result<TOut>> => { const { user, orgId, role: actorRole } = await requireOrgUser();
if (!roleAtLeast(actorRole, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors); }
const ctx = { user, orgId, role: actorRole, db: tenantDb(orgId) }; return fn(parsed.data, ctx); };Resolve identity. requireOrgUser() reads the session and active org. No session redirects to /sign-in; no active org to /onboarding/create-org. Those are framework control-flow exits, and the wrapper lets them go. But if the read itself breaks, the membership query throwing on a dead connection or returning a null the types ruled out, that exception flies past to error.tsx, which refuses. Nobody caught it into an allow, so it fails closed.
export const authedAction = <Schema extends z.ZodType, TOut>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Result<TOut>>, ) => async (formData: FormData): Promise<Result<TOut>> => { const { user, orgId, role: actorRole } = await requireOrgUser();
if (!roleAtLeast(actorRole, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors); }
const ctx = { user, orgId, role: actorRole, db: tenantDb(orgId) }; return fn(parsed.data, ctx); };Authorize. roleAtLeast compares the caller’s real role against the floor. Too low, and the wrapper returns err('forbidden', …), a value the form renders in place. The only way past this line is a role that cleared the bar.
export const authedAction = <Schema extends z.ZodType, TOut>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Result<TOut>>, ) => async (formData: FormData): Promise<Result<TOut>> => { const { user, orgId, role: actorRole } = await requireOrgUser();
if (!roleAtLeast(actorRole, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors); }
const ctx = { user, orgId, role: actorRole, db: tenantDb(orgId) }; return fn(parsed.data, ctx); };Validate input. Input that fails the schema returns err('validation', …) with per-field messages. A malformed payload never reaches the work below.
export const authedAction = <Schema extends z.ZodType, TOut>( role: Role, schema: Schema, fn: (input: z.infer<Schema>, ctx: Ctx) => Promise<Result<TOut>>, ) => async (formData: FormData): Promise<Result<TOut>> => { const { user, orgId, role: actorRole } = await requireOrgUser();
if (!roleAtLeast(actorRole, role)) { return err('forbidden', 'You do not have permission to do this.'); }
const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors); }
const ctx = { user, orgId, role: actorRole, db: tenantDb(orgId) }; return fn(parsed.data, ctx); };Run the body. Three gates have each proven their piece: real user, sufficient role, valid input. Only now does the wrapper build ctx (with db already tenant-scoped) and hand it to fn.
This is also the course’s one sanctioned wrapper around your tools. It earns the exception because authorization at the action boundary has a recurring bug class, the missing or fumbled check, and one structural seam closes that class for every action at once.
Where the throw and the Result both land
Section titled “Where the throw and the Result both land”The wrapper handles failure two ways: it lets an unexpected throw fly (the membership read blew up), or it returns a Result (role too low, bad input). That is the throw-versus-return split from the Result type, and fail-closed holds across both, because both reach the same outcome.
Throw at the framework edge for unexpected failures: the database is down, a programmer error fired. Return Result for expected failures the caller branches on: a refusal, a validation error. Watch what the user gets in each case.
- An unexpected throw inside a check propagates to the nearest
error.tsx, which renders a generic error page. The user sees an error. Not the resource. - An expected refusal returns
{ ok: false, error: { code: 'forbidden' } }, and the form renders “You don’t have permission.” The user sees a refusal. Not the resource.
Two different code paths, one a thrown exception caught by the framework and one a typed value read by the form, converging on the same security outcome. That is what fail-closed buys: it does not matter how the check failed, the user does not get the resource either way. The third exit, requireOrgUser redirecting a signed-out user to /sign-in, is not a failure; it is framework control flow, and we look at it next.
Pages and layouts: errors versus control flow
Section titled “Pages and layouts: errors versus control flow”Pages and layouts gate access with the same helper a Server Action would: a protected page calls requireOrgUser() near the top. A missing session redirects the visitor to /sign-in, a missing active org to /onboarding/create-org, and a broken check, say a session read that throws on a dead connection, flies to the framework’s error.tsx. Each path stops the visitor before the page body runs. None of them turns the failure into an allow, so the page is fail-closed.
That boundary is where juniors slip, because two different kinds of thrown value flow through it and only one is a failure.
- A thrown
Error, like a dropped Postgres connection inside atenantDbquery, is a genuine failure.error.tsxcatches it and the user gets the error boundary instead of the page. This is the fail-closed case. - A thrown
notFound()orredirect()is framework control flow; it is howrequireOrgUserbounces a signed-out or org-less user. The framework uses a thrown value to unwind the render, then routes tonot-found.tsxor the redirect handler.error.tsxnever sees it, and the fail-closed rule does not apply, because it is not a failure.
The webhook seam: refuse aggressively, retry safely
Section titled “The webhook seam: refuse aggressively, retry safely”The webhook receiver is where fail-closed matters most, because the caller on the other side is a machine that retries. When a Stripe event arrives, the receiver runs it through three gates, each of which fails closed. They refuse with different status codes, because the provider reads the status to decide what to do next.
Walk an event through it.
The whole receiver fails closed: when anything is uncertain, it refuses.
Now consider what makes fail-closed safe here. On a transient database error the receiver throws: it returns a 500 and does nothing. Has the subscription change just been dropped? No, because fail-closed and idempotency are paired primitives. The provider sees the 500 and retries the event, which re-runs the receiver. The dedup ledger catches the duplicate if the work had partially landed, and the transaction guarantees the business work runs exactly once across every retry. You refuse aggressively and retry safely, and fail-closed is comfortable here only because the retry is idempotent.
The rate limiter: the one deliberate exception
Section titled “The rate limiter: the one deliberate exception”Everything so far has pushed one way: refuse when in doubt. Here the course deliberately fails open.
The rate limiter on the authentication path is fail-open. When it calls Redis to check whether an IP has tried to sign in too many times and Redis is down, the limiter does not refuse the sign-in. It allows it. If it failed closed instead, a Redis outage would lock every user on the platform out of their own account until Redis came back. That is a self-inflicted, total outage triggered by a dependency hiccup, strictly worse than what the limiter exists to prevent: a brief window where an attacker gets a few extra password guesses.
The decision lives in exactly one helper, safeLimit, so “fail-open on the auth path” is one auditable piece of code rather than a convention you hope everyone remembers.
// The one place the fail-open policy lives. On a Redis outage `limit` throws;// we log it and return a passing verdict so the auth path stays up.export const safeLimit = async (limiter: Ratelimit, key: string) => { try { return await limiter.limit(key); } catch (error) { logger.error({ event: 'rate_limit_unavailable', error }); return { success: true }; }};The catch logs the outage at error level, so an operator gets paged that the limiter is degraded, then returns the verdict that lets the request through. Flipping this gate to fail-closed is a one-line change: return { success: false } instead.
The rule is the payoff: fail-closed is the default discipline; fail-open is a deliberate carve-out with a written reason, in one place. The reason matters as much as the carve-out: “a Redis outage shouldn’t lock everyone out” is the justification that makes this engineering rather than laziness. The default can flip the other way for a different gate. A rate limiter in front of a destructive admin action, or a billing webhook the customer cannot retry, might fail closed, because there the cost of wrongly allowing is higher than the cost of wrongly refusing.
What does not fail closed: access decisions versus product defaults
Section titled “What does not fail closed: access decisions versus product defaults”Once you learn fail-closed, the temptation is to apply it everywhere, including decisions that have nothing to do with access. Knowing where the rule stops is as senior as knowing where it holds, so draw the boundary precisely.
Compare two reads that both default to something on failure.
- A feature-flag check that defaults to
offwhen Redis is unreachable is fail-closed. The flag gates a feature, so it is an access decision, andoffis the safe side: the feature stays disabled until you can prove it should be on. - A theme preference that defaults to
'system'when its read fails is neither fail-open nor fail-closed. There is no security boundary. Whether the UI renders light or dark on a failed read is a product call, and'system'is just a sensible default.
The rule applies to access decisions: who can read, who can write, what tenancy enforces, which tier is allowed past a gate. Outside that boundary, “what should this default to on failure?” is a normal product question, neither fail-closed nor fail-open. Do not strain to fit a theme toggle into a security frame.
Take any check and walk it through the funnel.
The check proves the request is allowed or it throws; one wrapper catches the throw and refuses. The user gets an error or a 403, never the resource. This is the shape you want at every access seam.
A check that returns false or null on its own failure has buried “the check broke” inside a value the next caller will misread as “no.” Make it throw instead, and let the wrapper catch it. One value cannot safely mean both “denied” and “don’t know.”
A call-site try/catch that logs and proceeds turns a broken check into an allow. Logging is not denying. Remove the catch, and let the check throw to the wrapper that refuses in one place.
No security boundary means no fail-closed obligation. Pick the default that makes the best product sense ('system' for a theme, an empty list for a failed non-sensitive read). Do not strain to fit it into the access frame.
Audit for bypassed wrappers
Section titled “Audit for bypassed wrappers”The structural guarantee only holds when the wrappers actually run. authedAction makes fail-closed the only path for every action that goes through it; a Server Action that skips it has no gate at all.
So the two bypasses are the two bugs.
- A Server Action that does not go through
authedAction. - A route handler that does not go through
authedRoute.
Grep your own surface for both.
- Search for
'use server'in files that do not importauthedAction. - Search for exported
GET/POST/PUT/PATCH/DELETEinroute.tsfiles that do not importauthedRoute.
Review every hit. Some are legitimate exceptions, and those get named and documented: the public sign-up action that cannot require a session because there is not one yet, or the webhook receiver that gates with its own signature verify instead. The rest are holes, and they get migrated onto the wrapper.
You can now ask, of any seam in the codebase, the question this lesson promised: if this check throws, does the user get the resource, or get refused? That single question is the whole audit, and you can run it on a page, an action, a webhook, or a rate limiter without looking anything up. The next lesson takes the other half of the catch site: not whether the request is refused, but what the user is told versus what the operator records when it is.
External resources
Section titled “External resources”The framework's own reference for the error boundary — note how production builds redact the message and ship a digest, the fail-closed default you inherit for free.
The vendor-neutral case for the rule of this lesson: when a security control fails, it must default to denying access.
The provider's own reference for the webhook seam — signature verification, the retry-on-failure contract, and recording processed event IDs so a refusal is safe to retry.
The design-principles layer above this lesson: failing to a secure default is one control, defense in depth is why you stack several so a single broken gate isn't the whole wall.