Guards that throw, because a forgotten if fails open
5 min read
Every route handler under app/api/ in the DevPilot platform calls exactly one
authorization guard as its first statement, and every one of those guards
throws. None of them returns a boolean, an error object, or a discriminated
union. That is a deliberate choice, and it exists because the alternative
already failed here once.
The shape of the failure
An earlier version of the bridge API checked authorization the way most code does — by asking, and then acting on the answer:
const ctx = await getOrgContext(orgId)
if (!ctx) return NextResponse.json({ error: 'not found' }, { status: 404 })
// ... handler
This is fine when it is there. The problem is what happens when it is not.
A handler written in a hurry, or copied from a sibling route and edited, or
added during a refactor that moved the check one layer up and then moved it
back — any of those produces a route with no if. And a route with no if
does not fail. It succeeds, for everybody, and returns another tenant's data
with a 200.
That is the defining property of a check that returns: omitting it is indistinguishable from passing it. There is no signal. The tests pass, because the tests exercise the authorized path. The types check, because a discarded return value is legal TypeScript. Code review has to notice an absence, which is the thing human review is worst at.
Making the omission impossible
The guards throw instead:
export async function requireSession(): Promise<SessionCtx> {
const supabase = await createClient()
const { data, error } = await supabase.auth.getUser()
if (error || !data?.user) throw unauthenticated()
return { userId: data.user.id, email: data.user.email ?? '' }
}
The signature returns SessionCtx, not SessionCtx | null. There is no
falsy branch to forget, because there is no falsy value. A handler that needs
the user id has to call this, and calling it is the check.
The distinction is small in code and total in consequence. With a returning
check, safety is a property of the caller remembering. With a throwing check,
safety is a property of the value existing. You cannot get the userId without
having been authenticated, in the same way you cannot read a field off a value
you never obtained.
This is the same move as requiring a transaction handle for an enqueue, which is how the dispatch path made a lost-write window unrepresentable rather than merely discouraged. Both replace "remember to" with "cannot express otherwise".
Non-membership is 404, never 403
The second decision in that file is smaller and gets argued about more:
// Not a member => 404. Do not distinguish "org does not exist" from
// "you are not in it".
if (rows.length === 0) throw notFound()
A 403 is the semantically correct status. It also confirms the resource exists.
Given an endpoint that returns 403 for organizations you are not in and 404 for organizations that do not exist, anyone with a valid session can enumerate which organization ids are real. On a platform where the identifier is a cuid2 that is not a catastrophe, but it is free information about a customer's existence, handed to an unauthenticated-for-this-resource caller, forever.
Returning 404 costs one thing: a developer debugging a genuine membership problem sees "not found" and briefly suspects a bad id. That is a worse debugging experience for a handful of people and a strictly smaller information surface for everyone. We took the trade.
The rule generalises: an error response should not answer a question the caller was not authorized to ask. "Does this org exist" is such a question.
Proving the guards are actually there
A guard that throws is only useful on routes that call it, so "every handler
calls exactly one" needs to be enforced rather than asserted. A CI gate walks
app/api/, parses each route module, and fails the build on any exported
handler whose first statement is not a guard call — with a small allowlist for
the three genuinely public routes.
That last part matters more than the rule. The allowlist is what makes the gate
survivable: without it, the Linear webhook and the cron endpoint would fail
forever, someone would add || true, and the gate would join the large family
of checks that are green regardless of outcome. An exception you can name is
much safer than a rule you cannot keep.
The webhook is worth noting as the case that proves the shape. It cannot use a
session guard — Linear does not send a bearer token — so its authentication is
an HMAC signature over the raw request body, and the signature is
mandatory. An earlier version read if (signature && workspace.webhookSecret),
which meant omitting the header skipped verification entirely: anyone who
learned a Linear organization id could forge a dispatch. A missing header and a
missing stored secret are each a 401 now. Same disease, same cure — there is no
path that writes without verifying.
What this does not solve
Guards establish who is calling and which tenant they may act within. They say nothing about whether a particular column of that tenant's data should reach a browser. That is a different failure with a different fix, which is the encryption and column-grant story.
And none of this helps if a guard is called with the wrong org id — passing
params.orgId when you meant the session's own org is a bug the type system
cannot see, because both are strings. That one is still on us.
