UnlockOS Developers
← Back to blog
🔐

Fail-Closed Authorization: Lessons from Hardening Invite Flows

Jun 29, 2026Jul 5, 2026
9 min
77 commits
Depth 8/10
securityauthorizationrlstypescripterror-handling

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:

  1. UUID validation happens before the query. A malformed identifier should be rejected as invalid input, not coerced into a query that may behave unexpectedly.
  2. 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:

  1. 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.
  2. 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. reportUnmappedError turns "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-lockfile

Without 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.

Key Insights

1
Security

Authorization must bind the caller to the specific resource

Cross-tenant escalation happens when a check verifies that the caller holds a privileged role somewhere, rather than on the exact facility or organization being mutated. Helpers like checkFacilityOwnerAccess must take both the caller ID and the resource ID.

2
Security

Use allow-lists with TypeScript assertion signatures for role assignment

Deny-lists rot as new privileged roles appear. An explicit Set of assignable role keys combined with an `asserts` function fails closed on unknown roles and narrows the type so unsafe strings cannot reach persistence.

3
Security

RLS policies of USING (true) leak invitation metadata

Pending-invite tables map who will gain access to which property. Scope SELECT to the invitee (via JWT email claim) and facility owners, and avoid predicates that join against the auth schema's user table.

4
Reliability

Claim synchronization must retry and surface failure

When roles live in JWT claims, a silent sync failure means the database and the session disagree, potentially leaving revoked access active. Retry with exponential backoff, record an audit event, and show the operator a terminal error toast.

5
Error Handling

Stable error codes at the trust boundary prevent leakage and enable i18n

Server functions return machine-readable codes; the client maps them to localized strings and reports unmapped codes as telemetry. Raw server text never reaches the UI, and retry logic keys off codes rather than fragile string matching.

6
Reliability

Double-submit guards need a synchronous ref, not React state

setState is asynchronous and will not block a second click in the same tick. A useRef flag updates synchronously, but client guards are UX only — the server must still be idempotent for single-use credentials like OTPs.

7
Supply Chain

Frozen lockfiles and green CI are security properties

pnpm install --frozen-lockfile guarantees the tested artifact is the shipped artifact. A permanently red test suite trains engineers to ignore failures, which defeats every other guarantee in the system.