Fail-Closed Authorization: Lessons from Hardening Invite Flows
When a SDK controls physical doors, an authorization bug is not a data leak — it is a stranger standing in someone's living room. Over the last several weeks our team shipped a cluster of changes across the access-control layer: closing a cross-organization privilege escalation, adding a role_key allow-list with fail-closed defaults, removing over-permissive storage read policies, and making claim propagation observable instead of silently best-effort.
This article distills those changes into patterns you can apply to any multi-tenant system where the blast radius of a mistake is physical.
1. The Cross-Tenant Escalation Pattern
The most dangerous authorization bugs are rarely "no check at all." They are a check that verifies the wrong relationship.
Consider an invite endpoint. The caller supplies a facilityId and a roleId. A naive implementation validates that the caller is an owner somewhere, then writes the membership row:
// ❌ Vulnerable: verifies the caller is an owner, but not of THIS facility
async function inviteMember(caller: User, input: InviteInput) {
const isOwner = await db.memberships.exists({
userId: caller.id,
role: 'owner',
});
if (!isOwner) throw new ForbiddenError();
return db.invitations.insert({
facilityId: input.facilityId,
roleId: input.roleId,
});
}An owner of Org A can now invite themselves — at any role — into a facility belonging to Org B. The check passed. The authorization failed.
The fix is to make the subject and the object of the check explicit and inseparable:
// ✅ The check binds caller identity to the specific resource
export async function checkFacilityOwnerAccess(
callerId: string,
facilityId: string,
): Promise<AccessResult> {
if (!isUuid(facilityId)) {
return { allowed: false, reason: 'INVALID_FACILITY_ID' };
}
const row = await db.memberships.findOne({
userId: callerId,
facilityId,
status: 'active',
});
if (!row) return { allowed: false, reason: 'NOT_A_MEMBER' };
if (!OWNER_TIER_ROLES.has(row.roleKey)) {
return { allowed: false, reason: 'INSUFFICIENT_ROLE' };
}
return { allowed: true, roleKey: row.roleKey };
}Two details matter more than they look:
- UUID validation happens before the query. A malformed identifier should be rejected as invalid input, not coerced into a query that may behave unexpectedly.
- The function returns a structured reason. This feeds audit logs without leaking the reason back to the caller (more on that below).
Define it once, then actually call it
We also found a place where checkFacilityOwnerAccess was defined but a nearby list endpoint had inlined its own weaker gate. Defining a helper does not enforce it. A lint rule or an architectural test is worth the effort:
// tests/arch/authz.test.ts
import { readdirSync, readFileSync } from 'node:fs';
const MUTATING_HANDLERS = /export async function (create|update|delete|invite)/;
it('every mutating handler references an authz helper', () => {
for (const file of listHandlerFiles()) {
const src = readFileSync(file, 'utf8');
if (!MUTATING_HANDLERS.test(src)) continue;
expect(src).toMatch(/check(FacilityOwnerAccess|OrgAdminAccess)/);
}
});2. Allow-Lists, Not Deny-Lists, for Role Assignment
Role escalation usually enters through a field the API forwards without thinking. If a client can send roleKey: "platform_admin" and the server stores whatever arrives, you have handed out root.
Deny-lists rot: a new privileged role is added and nobody updates the blocklist. Allow-lists fail closed by construction.
const ASSIGNABLE_BY_OWNER = new Set([
'facility_manager',
'front_desk',
'housekeeping',
'member',
] as const);
type AssignableRole = typeof ASSIGNABLE_BY_OWNER extends Set<infer T> ? T : never;
export function assertAssignableRole(
roleKey: string,
callerRole: string,
): asserts roleKey is AssignableRole {
// Unknown role keys are rejected, not ignored.
if (!ASSIGNABLE_BY_OWNER.has(roleKey as AssignableRole)) {
throw new AuthzError('ROLE_NOT_ASSIGNABLE', { roleKey, callerRole });
}
}The TypeScript asserts signature is doing real work here: after the call, the compiler narrows roleKey to the union of safe values, so downstream code cannot accidentally pass an arbitrary string into the persistence layer.
Give roles a stable key, not a UUID
We introduced a role_key column alongside the existing roles.id. Authorization logic that switches on a UUID is unreadable and unreviewable; a reviewer cannot tell whether a3f1... is front_desk or platform_admin. A stable string key makes policy code auditable:
ALTER TABLE roles
ADD COLUMN role_key text;
UPDATE roles SET role_key = slugify(name) WHERE role_key IS NULL;
ALTER TABLE roles
ALTER COLUMN role_key SET NOT NULL,
ADD CONSTRAINT roles_role_key_unique UNIQUE (role_key),
ADD CONSTRAINT roles_role_key_format CHECK (role_key ~ '^[a-z][a-z0-9_]*$');Rolling this out in phases — add the column, backfill, enforce NOT NULL, then migrate policies — keeps the change reversible at every step.
3. Row-Level Security: The Pending-Invite Leak
Invitation tables are deceptively sensitive. They contain email addresses, target facilities, and intended roles — a map of who is about to gain access to what.
A common RLS policy looks like this:
-- ❌ Any authenticated user can enumerate pending invitations
CREATE POLICY select_invitations ON facility_invitations
FOR SELECT TO authenticated
USING (true);The intent was "an invitee needs to read their own invite before they have a membership row." The implementation granted global read.
The corrected policy scopes reads to the two legitimate audiences: the invitee (matched on their verified identity) and facility owners.
DROP POLICY IF EXISTS select_invitations ON facility_invitations;
CREATE POLICY select_own_invitation ON facility_invitations
FOR SELECT TO authenticated
USING (
lower(invited_email) = lower(auth.jwt() ->> 'email')
OR EXISTS (
SELECT 1 FROM memberships m
WHERE m.facility_id = facility_invitations.facility_id
AND m.user_id = auth.uid()
AND m.role_key IN ('owner', 'org_admin')
)
);The auth.users footgun
A related trap: writing RLS predicates that join against the authentication schema's user table. Those tables often have their own privileged access rules, and referencing them from a tenant policy can either error under restricted roles or silently widen visibility. Prefer claims already present in the JWT (auth.uid(), auth.jwt() ->> 'email') or a table you own. Your policy should depend only on data you control.
4. Storage Buckets Need the Same Discipline
We shipped a fix that created a missing id-documents bucket and dropped unsafe read policies on it. Object storage is frequently the weakest link, because a bucket created ad-hoc during development inherits whatever default the console offered.
Two rules for buckets holding identity documents:
- The bucket must exist as code. A missing bucket causes runtime errors that developers "fix" by creating one manually with permissive settings. Declare it in a migration.
- No broad
SELECT. Reads go through short-lived signed URLs issued by a server function that performs its own authorization check.
INSERT INTO storage.buckets (id, name, public)
VALUES ('id-documents', 'id-documents', false)
ON CONFLICT (id) DO UPDATE SET public = false;
DROP POLICY IF EXISTS "id_documents_public_read" ON storage.objects;
-- No SELECT policy for `authenticated`. Reads require a signed URL
-- minted by a server function after an explicit authorization check.
CREATE POLICY "id_documents_insert_own" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'id-documents'
AND (storage.foldername(name))[1] = auth.uid()::text
);export async function getIdDocumentUrl(caller: User, reservationId: string) {
const access = await checkReservationStaffAccess(caller.id, reservationId);
if (!access.allowed) {
await audit.record('id_document.access_denied', {
callerId: caller.id,
reservationId,
reason: access.reason,
});
throw new ForbiddenError();
}
await audit.record('id_document.accessed', { callerId: caller.id, reservationId });
return storage.createSignedUrl(access.objectPath, { expiresIn: 60 });
}Note the audit entries on both branches. Denied attempts are the signal you most want during an incident review.
5. Claim Propagation Must Be Observable
When roles live in JWT claims, changing a role requires a second step: refreshing the token. If that step is fire-and-forget, you get a silent, hard-to-reproduce class of bug where the database says one thing and the session says another — in a system controlling locks, that can mean a revoked staff member retaining access until their token expires.
We moved claim synchronization from an untracked side effect to an explicit, retried, and surfaced operation:
export async function syncClaims(userId: string): Promise<SyncResult> {
const MAX_ATTEMPTS = 3;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { error } = await functions.invoke('sync-user-claims', {
body: { userId },
});
if (error) throw error;
await audit.record('claims.synced', { userId, attempt });
return { ok: true };
} catch (err) {
lastError = err;
if (attempt < MAX_ATTEMPTS) {
await sleep(200 * 2 ** (attempt - 1));
}
}
}
await audit.record('claims.sync_failed', { userId, error: String(lastError) });
return { ok: false, error: lastError };
}And at the call site, failure is visible to a human:
const result = await syncClaims(member.userId);
if (!result.ok) {
toast.error(t('authz.claimsSyncFailed'));
// The DB write already succeeded; the operator must know the session
// may be stale and should force a re-login.
}Exponential backoff handles transient network failures. The terminal toast handles the rest. What you must never do is swallow the error — a permission change that appears to succeed but did not propagate is worse than one that visibly failed.
6. Stable Error Codes at the Trust Boundary
Several commits in this period converged on the same idea: server functions return machine-readable error codes, and the client maps them to localized messages.
export const CHECKIN_ERRORS = {
RESERVATION_NOT_FOUND: 'RESERVATION_NOT_FOUND',
RESERVATION_ALREADY_CHECKED_IN: 'RESERVATION_ALREADY_CHECKED_IN',
PLAN_NOT_AVAILABLE_ON_DATE: 'PLAN_NOT_AVAILABLE_ON_DATE',
PAYMENT_REQUIRED: 'PAYMENT_REQUIRED',
ROOM_HAS_ACTIVE_RESERVATIONS: 'ROOM_HAS_ACTIVE_RESERVATIONS',
} as const;
export type CheckinErrorCode =
(typeof CHECKIN_ERRORS)[keyof typeof CHECKIN_ERRORS];export function useResolvableError() {
const { t } = useTranslation();
return useCallback(
(err: unknown): string => {
const code = extractErrorCode(err);
if (code && i18n.exists(`errors.${code}`)) {
return t(`errors.${code}`);
}
// Unknown codes never surface raw server text to the user.
reportUnmappedError(code ?? 'UNKNOWN', err);
return t('errors.GENERIC');
},
[t],
);
}This has three security-adjacent benefits:
- No internal detail leakage. Stack traces, SQL fragments, and row identifiers never reach the UI.
- Deterministic client behavior. Retry logic keys off codes, not on string matching that breaks when a message is reworded or translated.
- Telemetry.
reportUnmappedErrorturns "the user saw a generic error" into an actionable signal.
A companion fix: clear stale errors on navigation. An error message from a previous step that lingers after the user moves on erodes trust and, worse, can mask a genuine new failure.
useEffect(() => {
setConfirmError(null);
}, [currentStep]);7. Idempotency and Double-Submit Guards
An OTP verify button that can be clicked twice will, in the worst case, consume a single-use code and then report failure on the second request — leaving the user locked out of a door.
function useVerifyOtp() {
const inFlight = useRef(false);
const [pending, setPending] = useState(false);
const verify = useCallback(async (code: string) => {
if (inFlight.current) return;
inFlight.current = true;
setPending(true);
try {
return await api.verifyOtp({ code });
} finally {
inFlight.current = false;
setPending(false);
}
}, []);
return { verify, pending };
}The useRef guard is deliberate: setPending is asynchronous and will not block a second click fired in the same tick. The ref updates synchronously.
Client-side guards are a UX improvement, not a security control. The server must still be idempotent — key the operation on a request identifier and return the original result for repeated calls.
8. Deterministic Builds Are a Supply-Chain Control
A small CI change carries outsized weight: using a frozen lockfile in every workflow.
- name: Install dependencies
run: pnpm install --frozen-lockfileWithout it, CI may resolve a different dependency tree than the one that was reviewed. For a system that provisions physical credentials, "the artifact we tested is the artifact we ship" is a security property, not a convenience. It also converts a silent drift into a loud CI failure the moment a lockfile and manifest disagree.
Relatedly: we spent a sprint fixing stale tests that had been red on the main branch. A permanently failing suite is equivalent to no suite — engineers stop reading it, and the next real regression slips through. Keeping CI green is a prerequisite for every other guarantee in this article.
Summary
The recurring theme across these changes is fail-closed by construction:
| Concern | Anti-pattern | Pattern |
|---|---|---|
| Resource authorization | Check the caller's role globally | Bind caller identity to the specific resource |
| Role assignment | Deny-list of dangerous roles | Allow-list of assignable roles |
| Row-level security | USING (true) for convenience |
Scope to invitee and owners explicitly |
| Object storage | Broad read policy on the bucket | No read policy; short-lived signed URLs |
| Claim propagation | Fire-and-forget | Retry with backoff, audit, surface failure |
| Errors | Free-text server messages | Stable codes mapped client-side |
| Builds | Floating dependency resolution | Frozen lockfile in CI |
None of these are exotic techniques. What makes them work is applying them consistently — and adding tests that fail when someone forgets. In a system that opens doors, the default answer to "should this be allowed?" must be no until something explicit says otherwise.