UnlockOS Developers
← Back to blog
🔐

Tenant Boundaries, Trusted Clocks, and Fail-Closed Locks

Sep 14, 2026Sep 20, 2026
9 min
294 commits
Depth 8/10
securitytypescriptmulti-tenancytestingreliability

Tenant Boundaries, Trusted Clocks, and Fail-Closed Locks

Introduction

Most bugs in a CRUD application produce a wrong number on a screen. In a system that issues credentials to physical door locks, the same class of bug produces a stranger standing in someone's room — or a paying guest locked out at 2 a.m. in front of a door that will not open.

This post collects the engineering patterns we lean on hardest in a smart lock management SDK: proving ownership at every write boundary, refusing to trust clocks we do not control, making cryptographic identifiers unlinkable, rotating webhook signing keys without downtime, and — critically — turning each fix into a permanent, automated guard so the same hole cannot be reopened six months later.

Every example below is generalizable. The specifics are ours; the shapes should be reusable in any multi-tenant system where a write has physical consequences.

1. Authorization is not "is the caller logged in?"

The single most dangerous bug pattern in a multi-tenant lock system is the confused deputy: an authenticated operator of Facility A sends a request that names a resource belonging to Facility B, and the server — having verified authentication but not ownership of the referenced IDs — happily performs the write.

The canonical example is assigning lock IDs to a room. The caller supplies an array of lock identifiers. If you only check "can this user edit this room?", you have not checked "does this user own these locks?".

interface AssignLocksInput {
  roomId: string;
  lockIds: string[];
  actor: { userId: string; organizationId: string };
}
export async function assignLocks(input: AssignLocksInput): Promise<void> {
  const room = await db.rooms.findOwnedBy(input.roomId, input.actor.organizationId);
  if (!room) throw new ForbiddenError('room_not_in_scope');
  // The critical second check: every referenced lock must belong to the same tenant.
  const owned = await db.locks.findAllByIds(input.lockIds, input.actor.organizationId);
  const ownedIds = new Set(owned.map((lock) => lock.id));
  const foreign = input.lockIds.filter((id) => !ownedIds.has(id));
  if (foreign.length > 0) {
    await audit.record('lock_assign.rejected', { actor: input.actor, foreign });
    throw new ForbiddenError('lock_not_in_scope');
  }
  await db.rooms.setLockIds(room.id, input.lockIds);
}

The rule we derived: every identifier that crosses the API boundary is untrusted input, even when it is a UUID, even when it came from a dropdown your own UI rendered. A UUID is not a capability.

The same rule applies transitively. When a proxy endpoint forwards a request to a vendor API, the proxy must re-derive scope from the session rather than passing through caller-supplied scope — otherwise the proxy becomes a privilege-escalation gadget that launders unauthorized requests through your trusted credentials.

2. Scope resources by facility, not just by organization

Organization-level scoping is table stakes. In hospitality, staff are usually assigned to specific properties, and a front-desk terminal in one building must not be able to enumerate rooms, stays, or issue keys in another.

We model this as a scope predicate resolved once per request and then threaded into every query:

export interface RequestScope {
  organizationId: string;
  facilityIds: readonly string[];
}
export function assertFacilityInScope(scope: RequestScope, facilityId: string): void {
  if (!scope.facilityIds.includes(facilityId)) {
    throw new ForbiddenError('facility_not_in_scope');
  }
}
export async function listStays(scope: RequestScope, facilityId: string) {
  assertFacilityInScope(scope, facilityId);
  return db.stays.where({ organizationId: scope.organizationId, facilityId });
}

Two failure modes are worth calling out because we hit both:

  1. Too permissive. Forgetting the predicate on one of several endpoints. A single unscoped list endpoint leaks the whole estate.
  2. Too strict. Deriving scope from a stale token claim, so staff who legitimately manage multiple facilities get a false 403. Scope should be resolved from the authoritative store at request time and cached deliberately, not baked into a long-lived token and forgotten.

Both are security-relevant. A false 403 at a front desk trains staff to look for workarounds, and workarounds are how boundaries die.

3. Push the boundary down to the database

Application-level checks are necessary but not sufficient, because application code is the layer that changes daily. Defense in depth means the database independently refuses writes that should never come from a client.

-- Client roles have no business writing workflow/state tables directly.
revoke insert, update, delete on public.notification_workflow_runs from authenticated, anon;
revoke insert, update, delete on public.reservation_keys from authenticated, anon;
-- Read paths stay policy-scoped.
alter table public.reservation_keys enable row level security;
create policy reservation_keys_read_in_scope on public.reservation_keys
  for select to authenticated
  using (facility_id in (select facility_id from public.staff_facility_scope where user_id = auth.uid()));

A recurring lesson: dangling grants outlive the code that needed them. A table gets refactored, the feature that required client writes is removed, and the grant stays. We now treat "which roles can write this table" as a reviewed artifact, with a CI check that pins the expected write surface and fails when it widens.

Two RLS antipatterns are worth guarding against automatically:

  • Policies that call auth.uid() per row instead of once per statement (a performance cliff that tempts people to disable RLS).
  • Policies written as using (true) with the real filter living in application code.

4. Never bill or expire against a clock you do not own

A lock credential has a validity window. A stay has a usage window. If either endpoint is computed from a guest device clock, the guest can change it. That is not a hypothetical abuse case — it is a one-line settings change on any phone.

// Wrong: the browser decides when the stay ends.
const endedAt = new Date();
// Right: the authoritative clock lives where the row lives.
const { now } = await db.rpc('server_now');
const endedAt = now;

The corollary is that displayed time and enforced time are different concerns. Enforcement uses the database clock in UTC. Display — and, importantly, day boundaries — uses the facility's timezone, not the browser's and not the server's deployment region.

export function stayWindowForLocalDay(
  localDate: string,
  facilityTimeZone: string,
  checkInLocalTime: string,
  checkOutLocalTime: string,
): { startsAt: Date; endsAt: Date } {
  const startsAt = zonedTimeToUtc(`${localDate}T${checkInLocalTime}`, facilityTimeZone);
  const endsAt = zonedTimeToUtc(`${localDate}T${checkOutLocalTime}`, facilityTimeZone);
  if (endsAt <= startsAt) throw new RangeError('invalid_stay_window');
  return { startsAt, endsAt };
}

Getting this wrong produces the worst kind of failure: it works perfectly in your timezone and silently expires keys an hour early — or late — for a property on the other side of a DST boundary. Note the RangeError: a window that cannot be valid should fail loudly at construction, not produce a key with a negative lifetime.

Related: when a vendor returns a token with a lifetime, clamp your stored expires_at to the actual lifetime rather than trusting an optimistic field. We once carried a token that claimed a long life and died on idle after roughly an hour; clamping turned a mysterious intermittent outage into a deterministic refresh.

5. Identifiers that cannot be correlated: keyed, scoped fingerprints

We needed a stable fingerprint for "who picked up this key" without storing raw personal identifiers. The naive implementation is a plain hash:

// Weak: a bare SHA-256 of a low-entropy identifier is trivially reversed
// by dictionary attack, and the same input yields the same digest everywhere,
// which makes recipients correlatable across stays and facilities.
const fingerprint = sha256(email);

Email addresses and phone numbers come from a small, enumerable space. A bare digest is a pseudonym, not a protection. The fix is a keyed MAC with a per-context salt, so the output is both unguessable without the key and non-correlatable across contexts:

import { createHmac } from 'node:crypto';
export function recipientFingerprint(params: {
  identifier: string;
  stayId: string;
  pepper: string;
}): string {
  const normalized = params.identifier.trim().toLowerCase();
  return createHmac('sha256', params.pepper)
    .update(`stay:${params.stayId}|id:${normalized}`)
    .digest('hex');
}

Three properties matter here:

  • Keyed. Without the pepper (stored as a managed secret, never in the repo), an attacker with database read access cannot brute-force the preimage.
  • Scoped. Binding the stay ID means the same person produces different fingerprints across stays, so a leaked table cannot be used to build a movement history.
  • Normalized. Deterministic normalization before hashing, or your "stable" identifier is not stable.

6. Rotating webhook signing keys without a maintenance window

Inbound webhooks are an authentication surface people forget is an authentication surface. Two hardening steps compound:

Per-organization signing keys. A single global secret means any tenant who learns it can forge events for every other tenant. Deriving or storing a key per organization turns a global compromise into a contained one.

A bounded acceptance window for the previous key. Rotation is only safe if it does not require perfect simultaneity between you and the sender.

import { createHmac, timingSafeEqual } from 'node:crypto';
const PREVIOUS_KEY_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
interface SigningKeys {
  current: string;
  previous?: { secret: string; rotatedAt: number };
}
function matches(secret: string, payload: string, signature: string): boolean {
  const expected = createHmac('sha256', secret).update(payload).digest();
  const provided = Buffer.from(signature, 'hex');
  if (expected.length !== provided.length) return false;
  return timingSafeEqual(expected, provided);
}
export function verifyInbound(
  keys: SigningKeys,
  payload: string,
  signature: string,
  now: number,
): 'current' | 'previous' | null {
  if (matches(keys.current, payload, signature)) return 'current';
  if (keys.previous && now - keys.previous.rotatedAt < PREVIOUS_KEY_GRACE_MS) {
    if (matches(keys.previous.secret, payload, signature)) return 'previous';
  }
  return null;
}

Note timingSafeEqual and the length pre-check — signature comparison with === leaks information through timing. Note also that a 'previous' result should be logged as a metric: if previous-key traffic is still non-zero as the grace window closes, someone has not finished rotating, and you want to know before the cutover, not after.

Verifying the signature is only half the job. The event still names a tenant, and that name must be cross-checked against the key that signed it:

const keyOrigin = await resolveOrganizationForSigningKey(usedKeyId);
if (keyOrigin !== event.organizationId) {
  await audit.record('webhook.tenant_mismatch', { keyOrigin, claimed: event.organizationId });
  return respond(202); // Acknowledge, but perform no side effects.
}

This check belongs on every branch that has side effects, including the ones added later. Our experience is that the first implementation gets it right on the main path and misses the branch added three weeks afterward — which is exactly why the next section exists.

7. Classify authentication failures instead of collapsing them

401 is a single status code hiding several very different operational situations. Collapsing them destroys your ability to respond.

export type AuthFailure =
  | { kind: 'credentials_rejected'; retryable: false }
  | { kind: 'token_expired'; retryable: true }
  | { kind: 'upstream_unavailable'; retryable: true };
export function classify(response: Response, body: unknown): AuthFailure {
  if (response.status >= 500) return { kind: 'upstream_unavailable', retryable: true };
  if (isExpiredTokenBody(body)) return { kind: 'token_expired', retryable: true };
  return { kind: 'credentials_rejected', retryable: false };
}

The operational payoff is concrete. credentials_rejected means stop retrying and page a human — retrying rotated-away credentials in a loop is how you get rate-limited out of your own lock vendor. token_expired means refresh once and continue. upstream_unavailable means back off with jitter.

Equally important: log a stable machine-readable code plus the reason, and make sure the raw upstream error never reaches a guest's screen. A guest at a door should see actionable guidance in their language; the diagnostic string belongs in your audit log.

8. Fail closed, and scope your countermeasures to the threat

Two lessons that pull in opposite directions and must be held together:

Fail closed on identity. If a resolution step cannot prove ownership — say, mapping an external platform ID to a tenant — it must reject, not fall back to a default. A fallback in an ownership resolver is an authorization bypass wearing a helpful hat.

export async function resolveTenantForExternalId(externalId: string): Promise<string> {
  const mapping = await db.externalMappings.find(externalId);
  if (!mapping) throw new ForbiddenError('ownership_unproven'); // never: return DEFAULT_TENANT
  return mapping.organizationId;
}

But scope your blunt instruments. We shipped an IP-based restriction for an on-premise QR key flow, and discovered that an entire building shares one NAT egress address: a single bad actor would have blocked every guest on the property. The correction was to demote the IP signal from enforcement to detection — it now feeds anomaly logging while a cryptographic check does the actual gating.

The generalizable principle: a countermeasure whose blast radius exceeds the attack it prevents is a self-inflicted denial of service. Authenticate with signatures and ownership proofs; use coarse network signals for observability. Likewise, a bot-challenge widget belongs on public sign-up surfaces, not in front of a guest holding a signed, single-use credential at a door, where a challenge failure means someone sleeps in the corridor.

9. Make each fix permanent with an executable guard

Every item above was, at some point, a bug. Fixing a bug is cheap. Preventing its return is the actual engineering work, because the person who reintroduces it will not have read your post-mortem.

Our pattern is a fast, always-on CI job that asserts structural invariants by grepping the source and schema:

#!/usr/bin/env bash
set -euo pipefail
# Every side-effecting webhook branch must assert the tenant boundary.
branches=$(grep -rlE 'case .WEBHOOK_EVENT_' src/webhooks | sort)
for file in $branches; do
  if ! grep -q 'assertTenantMatches(' "$file"; then
    echo "::error file=${file}::missing assertTenantMatches in a side-effecting branch"
    exit 1
  fi
done

Three hard-won refinements:

Guards must be ratcheted in both directions. We count our guards and fail CI if the count drops (a guard was deleted) or if it rises without updating the recorded floor (a guard was added without registration). A one-directional ratchet silently permits deletion.

Source-inspection guards have known blind spots. Ours missed parenthesized call forms, aliased imports, and multi-line formatting. We maintain a written catalogue of the shapes that defeat grep-based guards, and each new blind spot is added to it with a regression test. A guard you believe in but that does not fire is worse than no guard, because it manufactures false confidence.

Verify the guard actually fails. Part of our self-review checklist is: deliberately reintroduce the bug locally and confirm CI goes red. An unverified guard is a comment.

Because these checks run on every pipeline, we collapsed a dozen second-long jobs into a single always-on job. Cheap guards get kept; slow guards get disabled during an incident and never re-enabled.

10. Tests that pin the boundary, not the rendering

Two testing habits matter disproportionately here.

Pin the projection exposed to unauthenticated roles. A test that asserts exactly which columns an anonymous read path returns will fail the day someone adds SELECT *. That is precisely the day you want to hear about it.

it('exposes only the guest-safe columns to anonymous callers', async () => {
  const row = await getCheckInData(anonClient, token);
  expect(Object.keys(row).sort()).toEqual([
    'checkInAt', 'facilityName', 'reservationCode', 'roomLabel', 'status',
  ]);
});

Do not assert on locale-formatted strings. Time-window tests that compare rendered text like "9/21 15:00" break across locales and CI runner configurations, and — worse — teams fix them by loosening the assertion until it no longer tests anything. Assert on the underlying instants instead.

// Fragile: depends on runner locale and formatter version.
expect(view.validUntilLabel).toBe('9/21 15:00');
// Durable: asserts the invariant that actually matters.
expect(view.validUntil.toISOString()).toBe('2026-09-21T06:00:00.000Z');
expect(view.validUntil.getTime()).toBeGreaterThan(view.validFrom.getTime());

Similarly, decouple time-dependent tests from the wall clock by injecting the clock. A test that is green until midnight is not a test.

11. Treat secrets as inventory

Unclassified secrets accumulate. Ours reached a point where nobody could answer "which environment is this value for, who rotates it, and what breaks if it leaks?" We now require every secret to carry classification metadata, and we run a cross-environment drift check that compares the set of keys present in each environment (never the values) and reports divergence.

Drift is a leading indicator of two real incidents: a production deployment missing a key it needs (outage), and a staging environment holding a production credential (breach). Both are cheap to detect and expensive to discover the other way.

Summary

The patterns that earned their keep:

  • Every inbound identifier is untrusted. Verify ownership of referenced resources, not just authentication of the caller — especially on proxy paths.
  • Scope by facility and enforce it in the database too. Revoke dangling client write grants; pin the write surface in CI.
  • Own your clock. Enforce with the database clock in UTC; compute day boundaries in the facility timezone; clamp externally supplied expiries.
  • Keyed, context-scoped fingerprints. A bare hash of a low-entropy identifier is a pseudonym, not a protection.
  • Per-tenant signing keys with a bounded previous-key grace window, constant-time comparison, and a tenant cross-check on every side-effecting branch.
  • Classify auth failures into retryable and non-retryable kinds; log stable codes, show guests actionable messages.
  • Fail closed on identity, but scope blunt countermeasures so a defense cannot outweigh the attack.
  • Convert every fix into a bidirectionally ratcheted, verified CI guard, and keep a catalogue of the shapes your guards cannot see.

None of this is exotic cryptography. It is the discipline of assuming that the caller is lying about identifiers, the device is lying about the time, and the future maintainer has never heard of the incident you are trying to prevent.

Key Insights

1
Security

A UUID is not a capability

Authenticating the caller is insufficient; every referenced resource ID (lock, room, stay) must be independently proven to belong to the caller's tenant before any write, including on proxy and forwarding paths.

2
Security

Keyed, context-scoped fingerprints over bare hashes

A plain SHA-256 of a low-entropy identifier like an email is reversible by dictionary attack and correlatable across contexts. An HMAC with a managed pepper, salted per stay, makes the value both unguessable and non-linkable.

3
Reliability

Enforce on the database clock, display in the facility timezone

Validity windows and billing must never derive from a guest device clock, which the guest controls. Enforcement uses the authoritative server clock in UTC while day boundaries are computed in the property's timezone.

4
Security

Rotate webhook keys per tenant with a bounded grace window

Per-organization signing keys contain a compromise, while accepting the previous key for a fixed window enables zero-downtime rotation. Signature verification must use constant-time comparison and cross-check the claimed tenant against the key that signed.

5
Error Handling

Classify auth failures into retryable and non-retryable kinds

Collapsing credential rejection, token expiry, and upstream outage into a single 401 removes the ability to respond correctly — retry, refresh, or page a human — and risks retry storms against a vendor API.

6
Reliability

Scope countermeasures to their blast radius

An IP-based block on a shared building NAT would deny every guest on the property. Coarse network signals belong in detection and logging; cryptographic ownership proofs do the actual gating.

7
Testing

Ratchet guards in both directions and verify they fail

Counting guards with a floor that only moves up permits silent deletion. Guards must be tested by deliberately reintroducing the bug, and known grep blind spots (parenthesized calls, aliased imports) catalogued.

8
Testing

Pin anonymous column projections and avoid locale-formatted assertions

Asserting the exact column set returned to unauthenticated callers catches an accidental SELECT * immediately, while asserting on underlying instants rather than rendered date strings keeps time tests meaningful across locales.