Fail Closed: Security Patterns for Smart Lock Backends
Introduction
When your software decides whether a physical door opens, the cost of a bug changes shape. A rendering glitch is an annoyance; a credential leaked one hour early is a stranger inside a building. Over the last weeks of work on the UnlockOS SDK, a consistent set of patterns emerged across dozens of commits: fail closed, disclose the minimum, make every write idempotent, and prove the result instead of assuming it.
This article extracts those patterns in a generalized form. The code is illustrative, not the internal implementation, but every pattern below came from a real defect we closed.
1. Fail Closed, Never Fail Demoted
A subtle and dangerous anti-pattern is the demoted default: when a lookup fails, the system substitutes a "safe-looking" empty value and then persists it. We hit this in an auth hook that enriches a JWT with roles and facility scope. If the role read failed transiently, the hook wrote roles: [] into stored claims — silently demoting a legitimate operator, and worse, teaching the system a lie.
The rule: a read failure is not the same as an empty result.
type ClaimSource = { ok: true; roles: string[] } | { ok: false; error: Error };
export async function buildClaims(userId: string, stored: StoredClaims): Promise<StoredClaims> {
const roles = await readRoles(userId);
const facilities = await readFacilities(userId);
if (!roles.ok || !facilities.ok) {
// Do NOT persist. Keep the previously verified claims and surface the failure.
logger.error('claim_read_failed', { userId, roles: roles.ok, facilities: facilities.ok });
return { ...stored, refreshedAt: stored.refreshedAt, degraded: true };
}
return persist({ userId, roles: roles.roles, facilities: facilities.ids, refreshedAt: Date.now() });
}The same principle applies to time-based gates. A check-in flow asked "is this entrance currently staffed?" to decide whether self check-in is allowed. If that read errored, the original code treated it as "not staffed" and let the guest in. The fix was a one-line inversion of the default:
const hours = await readStaffedHours(entranceId);
if (hours.error) {
// Unknown staffing state must block self-service, not permit it.
return deny('staffed_hours_unavailable');
}
if (isWithin(hours.value, now)) return deny('staffed_hours_active');
return allow();Checklist: for every catch block on an authorization input, ask what the branch permits. If the answer is "everything", you have an open door.
2. Client-Writable Authorization Tables Are a Privilege Escalation
A row-level security policy on a roles / role_members table is only as strong as its WITH CHECK clause. We shipped a hotfix for a policy that allowed any signed-in user to insert their own row into the role assignment table — effectively self-granting platform admin.
The durable fix is structural: authorization tables are never client-writable. All mutations go through a privileged function that re-checks the caller.
-- Deny all direct writes from the anon/authenticated roles.
revoke insert, update, delete on public.role_members from authenticated, anon;
alter table public.role_members enable row level security;
create policy role_members_read_self on public.role_members
for select to authenticated
using (user_id = auth.uid());
-- Mutations happen only here, under a definer that verifies the grantor.
create or replace function public.grant_role(target_user uuid, role_key text, org uuid)
returns void language plpgsql security definer set search_path = public as $$
begin
if not public.is_org_owner(auth.uid(), org) then
raise exception 'forbidden' using errcode = '42501';
end if;
insert into role_members(user_id, role_key, organization_id, granted_by)
values (target_user, role_key, org, auth.uid())
on conflict (user_id, role_key, organization_id) do nothing;
end;
$$;Two related defects reinforced the lesson:
- A create-facility verb did not require ownership of the target organization, only membership somewhere. Always authorize against the resource's tenant, never the caller's "any tenant".
- Tenant-scoped identifiers (lock/unit IDs from the hardware vendor) were readable cross-tenant because the view lacked an organization predicate. Identifiers are data too.
3. Minimum Disclosure: Allowlist, Don't Blocklist
Anonymous endpoints are the highest-value target in a lock system. A reservation lookup that accepts a date or a photo and returns "the reservation" will, by default, return everything on the row — including door PINs and QR payloads.
We moved from "strip the sensitive fields" to "project only the allowed fields", because blocklists silently break every time a column is added.
const ANON_RESERVATION_FIELDS = ['id', 'status', 'startAt', 'endAt', 'facilityName'] as const;
type AnonReservation = Pick<Reservation, typeof ANON_RESERVATION_FIELDS[number]>;
function projectAnonymous(row: Reservation): AnonReservation {
return ANON_RESERVATION_FIELDS.reduce((acc, key) => {
(acc as Record<string, unknown>)[key] = row[key];
return acc;
}, {} as AnonReservation);
}For searches that are not identity-proving (a date range, an uploaded image), we additionally mask human identifiers: "Yamada Taro" -> "Y. T.". The guest can confirm they found the right booking; a scraper learns nothing.
The strongest version of this rule is temporal: door credentials do not exist in any response until the state machine says the stay is admitted. Reservation list endpoints for guests withhold PINs and QR codes until check-in completes, which means an early-leaked list is worthless.
function serializeReservation(r: Reservation, viewer: Viewer): ReservationDto {
const base = projectBase(r, viewer);
const admitted = r.checkinState === 'checked_in' && r.reviewState !== 'pending';
return admitted ? { ...base, credentials: r.credentials } : base;
}4. Model Admission as an Explicit State Machine
Adding a "host review" step — where staff approve a guest roster before keys are issued — is where ad-hoc booleans go to die. We modelled it as a finite state machine with explicit refusal reasons, and made the SDK treat every non-terminal state as not checked in.
export type ReviewState =
| 'not_required'
| 'pending'
| 'sent_back'
| 'approved'
| 'rejected';
const TRANSITIONS: Record<ReviewState, ReviewState[]> = {
not_required: [],
pending: ['approved', 'rejected', 'sent_back'],
sent_back: ['pending'],
approved: [],
rejected: [],
};
export function transition(from: ReviewState, to: ReviewState): ReviewState {
if (!TRANSITIONS[from].includes(to)) {
throw new InvalidTransitionError(`${from} -> ${to}`);
}
return to;
}Three invariants made it trustworthy:
- Bind the decision to the artifact. An approval references the hash of the submitted roster. If the guest edits the roster after submission, the approval no longer applies and the check-in is refused with a dedicated
roster_changederror the SDK maps to a user-facing message. - Re-check gates at the moment of effect. Payment and eligibility are verified again at approval time, not only at submission time. Time passes between the two; facts change.
- Lock the input while a decision is in flight. The roster is immutable in
pending, which removes an entire class of TOCTOU races.
Recovery matters as much as the happy path. Approvals that got stuck between "decided" and "keys issued" are swept by a background job with bounded backoff, and a failure of the sweep read itself raises an alert rather than silently doing nothing.
5. Idempotency and Compare-and-Set for Shared Resources
Provisioning verbs (create plan, create entrance, upload media) now carry a client_key. Retries converge instead of duplicating — important when the caller is a flaky kiosk or an automated agent.
alter table plans add column client_key text;
create unique index plans_client_key_uidx on plans (organization_id, client_key)
where client_key is not null;export async function planManage(input: PlanInput, clientKey: string) {
const existing = await findByClientKey(input.organizationId, clientKey);
if (existing) return { planId: existing.id, created: false };
return insertPlan({ ...input, clientKey });
}For a shared credential — one daily QR per door shown on a kiosk display — idempotency is not enough; you need mutual exclusion. A read-then-write would let two kiosks mint two tokens for the same day. A conditional update (compare-and-set) makes the winner unambiguous:
update display_keys
set qr_token = $1, issued_at = now()
where entrance_id = $2
and day = $3
and qr_token is null
returning qr_token;If zero rows return, another process won; re-read and use theirs. The rotation order matters too: insert the new key before retiring the old one, so there is never a window in which a legitimate guest has no working credential.
6. Preview, Approve, Verify — Never "Probably Applied"
For bulk configuration changes we replaced direct writes with a three-phase pipeline:
- Preview: every intended write is rendered as a diff before approval. No write happens during planning.
- Human approval for human-only decisions: going live, enabling a free-entry door, anything with money or physical access consequences requires an explicit confirmation that a machine cannot supply.
- Post-apply verification: after writing, read back fees, entrances and every written row. A result is only reported green if it was actually verified; an unchecked result renders as unknown, never success.
Production applies additionally require two independent keys, so a single compromised credential cannot reconfigure a live building.
type ApplyResult =
| { status: 'verified'; rows: VerifiedRow[] }
| { status: 'unverified'; reason: string }
| { status: 'failed'; error: Error };
function render(result: ApplyResult): Badge {
if (result.status === 'verified') return Badge.Green;
return Badge.Warning; // 'unverified' must never look like success
}The capability catalog that drives this pipeline gained a writtenBy axis, and CI fails if any capability claims an effect without naming the component that performs the write. Unattributed writes are how systems drift.
7. Observability Without Leaking Secrets
We added error reporting to the guest-facing check-in host, then immediately added a scrubber — because the natural payload of a check-in error contains magic-link tokens in URLs, session paths, and non-plain objects whose serialization is unpredictable.
const TOKEN_PARAMS = ['token', 'access_token', 'code', 'otp', 'pin'];
function scrubUrl(raw: string): string {
try {
const url = new URL(raw);
for (const p of TOKEN_PARAMS) if (url.searchParams.has(p)) url.searchParams.set(p, '[redacted]');
url.hash = '';
return url.toString();
} catch {
return '[unparseable-url]';
}
}
function scrubContext(value: unknown): unknown {
if (typeof value === 'string') return value.includes('://') ? scrubUrl(value) : value;
if (value === null || ['number', 'boolean'].includes(typeof value)) return value;
return '[non-plain]'; // never serialize unknown objects into a third-party sink
}The mirrored concern is the audit log, which must be complete rather than redacted. An event-type allowlist had drifted and was silently dropping remote_unlock and key_revoke_failed rows — precisely the two events an incident investigation needs. Allowlists protect you until they quietly reject the evidence; treat the audit allowlist as a tested contract, not configuration.
alter table checkin_events drop constraint checkin_events_type_check;
alter table checkin_events add constraint checkin_events_type_check
check (event_type in ('checkin','checkout','key_issued','key_revoked',
'key_revoke_failed','remote_unlock','review_decided'));Similarly, vendor API tokens were moved out of the browser entirely: the client calls our edge function, which resolves the facility token server-side. A token the browser never holds cannot be exfiltrated from it.
8. Tests That Encode Security Invariants
Unit tests that assert happy paths do not protect access control. The tests that paid for themselves here assert structural properties:
it('every caller of the shared check-in body gates authorization first', () => {
const callers = findCallers('performCheckin');
for (const caller of callers) {
expect(caller.body).toCallBefore('assertCheckinAllowed', 'performCheckin');
}
});
it('anonymous lookup never returns credential fields', async () => {
const res = await lookupAnonymous({ date: '2026-10-26' });
for (const row of res.items) {
expect(Object.keys(row)).toEqual(expect.not.arrayContaining(['pin', 'qr', 'credentials']));
}
});We also pin call counts for sensitive operations (how many times a hold is placed, how many times a disclosure gate runs) in CI guards. A refactor that accidentally removes a gate changes the count and fails the build, even if every functional test still passes.
Flakiness is a security issue too: a test that stops an auth refresh interval before asserting key ordering is not cosmetic — a flaky suite trains teams to ignore red builds.
Summary
The patterns that make a physical-access system trustworthy are unglamorous and repeatable:
- Fail closed. A failed read is never an empty result, and never a persisted demotion.
- Authorization tables are never client-writable. Mutations go through a definer function that re-checks the grantor.
- Authorize against the resource's tenant, not the caller's membership anywhere.
- Allowlist response fields; withhold credentials until the state machine admits the stay.
- Model admission explicitly, bind decisions to a hash of what was decided, re-check gates at the moment of effect, and lock the input while pending.
- Make writes idempotent with a client key, and use compare-and-set for shared credentials; issue before retiring.
- Preview, approve, verify. An unverified apply must never render as success.
- Scrub what you export; complete what you audit.
- Test invariants, not just flows — including call counts on security gates.
None of these require exotic technology. They require deciding, for every branch, what happens when the answer is unknown — and making sure the door stays shut.