UnlockOS Developers
← Back to blog
🔐

Multi-Tenant Access Control: RLS, Claims, and Idempotent Webhooks

Jun 22, 2026Jun 28, 2026
9 min
90 commits
Depth 8/10
securityauthorizationpostgrestypescripttesting

Multi-Tenant Access Control: RLS, Claims, and Idempotent Webhooks

A smart-lock platform is only as trustworthy as its weakest boundary. When a single deployment serves many facilities — hotels, coworking spaces, residential buildings — every query, every JWT claim, and every inbound webhook is a potential cross-tenant leak. This article walks through a set of hardening patterns we applied across the authorization stack, the database layer, and the external-integration boundary, with generalizable code you can apply to any multi-tenant system.

1. Row-Level Security Needs WITH CHECK, Not Just USING

A very common Postgres RLS mistake: writing an UPDATE policy with only a USING clause. USING controls which rows you may see and target. WITH CHECK controls what the row is allowed to look like afterwards. Without the latter, a tenant can select a row they legitimately own and rewrite its facility_id to another tenant — effectively donating (or stealing) a record across the tenancy boundary.

-- VULNERABLE: caller can move the row to another tenant
CREATE POLICY check_ins_update ON check_ins
  FOR UPDATE
  USING (facility_id = ANY (current_facility_ids()));
-- HARDENED: the post-image must also stay inside the caller's scope
DROP POLICY check_ins_update ON check_ins;
CREATE POLICY check_ins_update ON check_ins
  FOR UPDATE
  USING (facility_id = ANY (current_facility_ids()))
  WITH CHECK (facility_id = ANY (current_facility_ids()));

The rule of thumb: every FOR UPDATE and FOR INSERT policy must have a WITH CHECK. Audit them mechanically:

SELECT schemaname, tablename, policyname, cmd
FROM pg_policies
WHERE cmd IN ('UPDATE', 'INSERT')
  AND with_check IS NULL;

If that query returns rows, you have a tenant-hopping hole.

2. Make Tenant Keys Structurally Immutable

RLS is a runtime guard. Defense in depth says the tenant key should also be immutable at the schema level, so that a service-role script, a migration, or a future policy regression cannot silently relocate records.

CREATE OR REPLACE FUNCTION assert_facility_id_immutable()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  IF NEW.facility_id IS DISTINCT FROM OLD.facility_id THEN
    RAISE EXCEPTION 'facility_id is immutable (table %, id %)', TG_TABLE_NAME, OLD.id
      USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END;
$$;
CREATE TRIGGER check_ins_facility_id_immutable
  BEFORE UPDATE ON check_ins
  FOR EACH ROW EXECUTE FUNCTION assert_facility_id_immutable();

The complementary front-end change matters just as much: stop sending the tenant key in edit payloads at all. If the client never transmits facility_id on update, there is no field to tamper with, and the server derives scope purely from the authenticated session.

type CheckInEditablePayload = Omit<CheckInRow, 'id' | 'facility_id' | 'created_at'>;
export function toEditPayload(form: CheckInForm): CheckInEditablePayload {
  const { facility_id: _ignored, ...editable } = form;
  return editable;
}

TypeScript's Omit turns "please don't send the tenant id" from a code-review convention into a compile-time guarantee.

3. Claims-Based Authorization: Mint Early, Verify Everywhere

Role checks that hit the database on every request are slow and easy to forget. Encoding facility-staff membership as additive JWT claims gives you a single, cheap source of truth — but only if claims are minted at the right moments and always treated as untrusted input until verified.

Two moments matter: initial login and role/facility switching. A subtle bug class is minting claims only in the second path, which leaves a first-time user with an empty claim set and a mysteriously empty UI.

export interface FacilityStaffClaims {
  facility_roles: Record<string, StaffRole>;
}
export type StaffRole = 'owner' | 'manager' | 'member';
const WRITE_ROLES: ReadonlySet<StaffRole> = new Set(['owner', 'manager']);
export function canRead(claims: FacilityStaffClaims, facilityId: string): boolean {
  return Boolean(claims.facility_roles[facilityId]);
}
export function canWrite(claims: FacilityStaffClaims, facilityId: string): boolean {
  const role = claims.facility_roles[facilityId];
  return role !== undefined && WRITE_ROLES.has(role);
}

The same claim shape should drive the database policy, so the UI and the data layer can never disagree:

CREATE OR REPLACE FUNCTION current_facility_ids()
RETURNS uuid[]
LANGUAGE sql
STABLE
AS $$
  SELECT COALESCE(
    ARRAY(
      SELECT (jsonb_object_keys(
        COALESCE(auth.jwt() -> 'facility_roles', '{}'::jsonb)
      ))::uuid
    ),
    ARRAY[]::uuid[]
  );
$$;

Role normalization is a security concern

When roles are spelled differently across environments (facility_manager vs manager, FACILITY_MEMBER vs member), permission checks silently fall through to the deny branch — or worse, to a permissive default. Normalize at the boundary and record the mapping in an ADR so it stays stable:

const ROLE_ALIASES: Record<string, StaffRole> = {
  owner: 'owner',
  facility_owner: 'owner',
  manager: 'manager',
  facility_manager: 'manager',
  member: 'member',
  facility_member: 'member',
};
export function normalizeRole(raw: string | null | undefined): StaffRole | null {
  if (!raw) return null;
  return ROLE_ALIASES[raw.trim().toLowerCase()] ?? null;
}

Note the fail-closed ?? null: an unrecognized role grants nothing. Then run an environment drift audit that compares the distinct role values in staging and production against the alias table — drift discovered by a scheduled job is far cheaper than drift discovered by a support ticket.

4. Invitations: Idempotent Accept, Tenant-Scoped Everything

Staff invitation flows are a classic cross-tenant hole because they mint privileges by design. Three properties are non-negotiable:

  1. The invite token is a random UUID, verified server-side — never a guessable email+facility pair.
  2. Acceptance is idempotent: double-clicking "Accept" must not create duplicate memberships or escalate a role.
  3. The invite is scoped to the selected facility, not to "whatever facility the creator currently has open."
CREATE OR REPLACE FUNCTION accept_staff_invite(p_invite_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  v_invite staff_invites%ROWTYPE;
BEGIN
  SELECT * INTO v_invite
  FROM staff_invites
  WHERE id = p_invite_id
    AND accepted_at IS NULL
    AND expires_at > now()
    AND lower(email) = lower(auth.jwt() ->> 'email')
  FOR UPDATE;
  IF NOT FOUND THEN
    RAISE EXCEPTION 'invite_invalid' USING ERRCODE = '42501';
  END IF;
  INSERT INTO facility_staff (facility_id, user_id, role)
  VALUES (v_invite.facility_id, auth.uid(), v_invite.role)
  ON CONFLICT (facility_id, user_id) DO NOTHING;
  UPDATE staff_invites SET accepted_at = now() WHERE id = v_invite.id;
END;
$$;

SECURITY DEFINER with a pinned search_path, a row lock, an expiry check, an identity check against the JWT, and ON CONFLICT DO NOTHING — each line closes a specific attack or race.

5. Webhook Attribution: Never Trust a Shared Identifier

External hardware sends webhooks back to you. If you attribute those events using a shared identifier — a lock id, a door id, a facility id — two concurrent operations on the same lock become indistinguishable, and an event can be recorded against the wrong record. The fix is to embed a per-operation UUID in the credential you issue, and to attribute strictly by that UUID.

export function buildPinTargetName(slipId: string): string {
  return `slip:${slipId}`;
}
export function resolveSlipIdFromWebhook(payload: WebhookPayload): string | null {
  const match = /^slip:([0-9a-f-]{36})$/i.exec(payload.targetName ?? '');
  return match ? match[1] : null;
}
export async function handleDeliveryWebhook(payload: WebhookPayload) {
  const slipId = resolveSlipIdFromWebhook(payload);
  if (!slipId) {
    await auditLog.warn('webhook.unattributed', { targetName: payload.targetName });
    return;
  }
  await recordDelivery({
    slipId,
    entranceId: payload.entranceId,
    occurredAt: parseVendorTimestamp(payload.createTime),
  });
}

Two details worth stealing:

  • Fail loudly but safely. An unattributable webhook is logged for audit rather than guessed at. Guessing is how events land on the wrong tenant.
  • Persist the correlating keys you will need later. Recording entrance_id alongside the delivery log is what makes camera/recording replay possible at audit time. If you don't store the join key at write time, the audit trail is effectively lost.

6. Timestamps Are a Correctness Boundary

Audit logs that are nine hours off are worse than no audit logs — they create false confidence. Vendor APIs often emit local wall-clock time without an offset. Parsing that string with a naive new Date() silently applies the server's timezone.

import { fromZonedTime } from 'date-fns-tz';
export function parseVendorTimestamp(raw: string, vendorZone = 'Asia/Tokyo'): Date {
  if (/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
    return new Date(raw);
  }
  return fromZonedTime(raw, vendorZone);
}

The same discipline applies inside the product: reservation times must be rendered and stored in the facility's timezone, not the browser's. A booking rendered in the viewer's locale but written in UTC-shifted form produces off-by-one-day access windows — a lock that opens on the wrong date is a security incident.

export function toFacilityInstant(localInput: string, facilityTimeZone: string): string {
  return fromZonedTime(localInput, facilityTimeZone).toISOString();
}

And watch inclusive range boundaries. A week view that computes start < day instead of start <= day will drop every reservation that begins exactly on the first day of the range — the kind of off-by-one that hides for months because it only affects one column of a grid.

7. One Source of Truth for Capacity Gates

A "shows available, returns 409 on submit" bug is a reliability failure with a security flavor: the availability calculation and the booking gate were two different code paths that drifted. The fix is structural — derive capacity from the authoritative relation, and have both the read path and the write path call the same function.

export function resolveCapacity(roomType: RoomType, linkedRooms: Room[]): number {
  return linkedRooms.reduce((sum, room) => sum + room.capacity, 0);
}
export function isSlotBookable(input: SlotInput): Result<true, BookingRejection> {
  if (!input.plan.availableDays.includes(input.dayOfWeek)) {
    return err({ code: 'DAY_NOT_AVAILABLE' });
  }
  if (input.occupied >= resolveCapacity(input.roomType, input.linkedRooms)) {
    return err({ code: 'CAPACITY_EXCEEDED' });
  }
  return ok(true);
}

The grid renderer and the POST handler both call isSlotBookable. Parity is then a property of the architecture rather than a promise in a code review. A discriminated Result type also keeps rejection reasons typed, so the UI can localize them precisely instead of falling back to a generic "error occurred."

8. Lock In the Behavior With Tests

Every guard above deserves a regression test, and the cheapest way to get them is a test harness that runs in CI from day one.

import { describe, expect, it } from 'vitest';
describe('accept flow guards', () => {
  it('is idempotent on repeated accept', async () => {
    await acceptInvite(inviteId);
    await acceptInvite(inviteId);
    const rows = await listStaff(facilityId);
    expect(rows.filter((r) => r.userId === userId)).toHaveLength(1);
  });
  it('rejects an unknown role fail-closed', () => {
    expect(normalizeRole('SUPER_ADMIN_X')).toBeNull();
  });
  it('keeps already-checked-out calls idempotent', async () => {
    const first = await checkOut(reservationId);
    const second = await checkOut(reservationId);
    expect(first.status).toBe('checked_out');
    expect(second.status).toBe('checked_out');
  });
});

Finally, apply least privilege to CI itself. GitHub Actions tokens default to broad write scopes unless you declare otherwise:

permissions:
  contents: read
  id-token: write

A workflow that only builds and tests needs contents: read and nothing else. This is a one-line change that shrinks the blast radius of a compromised third-party action.

Summary

Trust in a physical-access system is assembled from unglamorous, layered guarantees:

  • WITH CHECK on every write policy, plus immutable tenant keys enforced by triggers.
  • Tenant ids removed from client payloads and from the type that describes them.
  • Claims minted on both login and role-switch, normalized fail-closed, and shared by UI and database.
  • Invitations accepted through an idempotent, SECURITY DEFINER RPC with expiry and identity checks.
  • Webhooks attributed by per-operation UUIDs, with unattributable events audited rather than guessed.
  • Timestamps parsed and stored in an explicit timezone so audit trails are actually true.
  • A single shared predicate behind both availability display and the booking gate.
  • Tests and least-privilege CI that keep all of the above from regressing.

None of these is individually clever. Together they are what turns "we think it's secure" into "we can show you why."

Key Insights

1
Security

RLS UPDATE policies without WITH CHECK allow tenant hopping

USING controls which rows are targetable; WITH CHECK controls the post-image. Without it, a tenant can rewrite facility_id and move a record into another tenant. Audit pg_policies for UPDATE/INSERT policies with a NULL with_check clause.

2
Security

Make tenant keys immutable at the schema level

A BEFORE UPDATE trigger that rejects any change to facility_id provides defense in depth beyond RLS, and removing the field from client edit payloads (via TypeScript Omit) eliminates the tamperable input entirely.

3
Authorization

Mint claims on initial login as well as role-switch

Claims-based authz is fast and consistent, but only if claims exist at every entry point. Minting solely on role-select leaves first-time users with empty permissions; the same claim shape should drive both UI gates and database policies.

4
Authorization

Normalize role names fail-closed and audit env drift

Divergent role spellings across environments silently break permission checks. Normalize at the boundary with an alias table returning null for unknown values, and run scheduled drift audits comparing actual role values per environment.

5
Reliability

Attribute webhooks by per-operation UUID, never a shared lock id

Embedding a unique operation UUID in the issued credential makes inbound hardware events unambiguously attributable. Unattributable events should be audit-logged rather than heuristically matched, and correlating keys must be persisted at write time for later replay.

6
Reliability

Single source of truth for capacity and availability gates

The 'available in UI, 409 on submit' class of bug comes from duplicated logic. Deriving capacity from the authoritative relation and having both the read and write paths call the same typed predicate makes parity architectural rather than aspirational.

7
Correctness

Explicit timezone parsing protects audit-trail integrity

Vendor timestamps without offsets parsed via naive Date constructors land hours off, corrupting audit records and access windows. Parse with an explicit vendor zone and store/render reservation times in the facility timezone.

8
Testing

Idempotency and least-privilege CI as regression guards

Repeated accept and repeated checkout must converge to the same state; unit tests lock this in. Declaring minimal GitHub Actions permissions (contents: read) shrinks the blast radius of compromised third-party actions.