Fail-Closed Access Control in a Smart Lock Check-In Flow
Introduction
When an SDK decides whether a door opens, every default, every fallback, and every missing validation is a security decision. A recent development cycle on the UnlockOS SDK touched the guest check-in flow end to end: who may trigger a check-in, when a door key becomes valid, what happens to that key when a reservation is cancelled, and how the database itself refuses to leak rows.
This article extracts the generalizable engineering patterns from that work. None of them are exotic. All of them are the difference between a system that looks locked and a system that is locked.
1. An empty configuration means "deny", not "allow"
One of the most dangerous lines of code in any access-control layer is the convenience shortcut that treats an unset list as "everything".
A walk-in pricing configuration had a scope field listing which membership tiers may enter for free. The original implementation read an empty scope as no restriction — which, for a facility that had simply never configured the field, silently granted free entry to everyone.
type WalkInScope = { freeTierIds: string[] } | null;
// Dangerous: absence of configuration becomes universal permission.
function isFreeLegacy(scope: WalkInScope, tierId: string | null): boolean {
if (!scope || scope.freeTierIds.length === 0) return true;
return tierId !== null && scope.freeTierIds.includes(tierId);
}
// Fail-closed: absence of permission is not permission.
function isFree(scope: WalkInScope, tierId: string | null): boolean {
if (!scope) return false;
if (scope.freeTierIds.length === 0) return false;
if (!tierId) return false;
return scope.freeTierIds.includes(tierId);
}The rule we now apply across the codebase:
A permission predicate may only return
truebecause of data that explicitly saystrue. Missing, empty, or malformed configuration always resolves tofalse.
This is cheap to enforce in tests, and the test table is the documentation:
describe('isFree', () => {
it.each([
[null, 'tier_a', false],
[{ freeTierIds: [] }, 'tier_a', false],
[{ freeTierIds: ['tier_a'] }, null, false],
[{ freeTierIds: ['tier_a'] }, 'tier_b', false],
[{ freeTierIds: ['tier_a'] }, 'tier_a', true],
])('scope=%j tier=%s -> %s', (scope, tier, expected) => {
expect(isFree(scope as WalkInScope, tier)).toBe(expected);
});
});2. A gate that only hides a button is not a gate
Two separate fixes in this cycle shared one root cause: a restriction existed in the UI but not on the server. Deleted booking plans were still accepted by the create endpoint. Membership-tier gates were documented as UI affordances rather than enforced rules.
The remediation pattern is to classify every gate explicitly and require that anything labelled enforced has a server-side assertion plus a test that calls the endpoint directly.
type GateLevel = 'cosmetic' | 'advisory' | 'enforced';
interface FeatureGate {
id: string;
level: GateLevel;
/** Required when level === 'enforced'. Runs on the server. */
assert?: (ctx: RequestContext) => void;
}And the endpoint re-validates everything the client claimed:
export async function createReservation(
input: CreateReservationInput,
ctx: RequestContext,
): Promise<Reservation> {
const plan = await plans.findById(input.planId);
if (!plan) {
throw new DomainError('PLAN_NOT_FOUND', 'Unknown plan.');
}
if (plan.facilityId !== ctx.facilityId) {
// Cross-tenant guard: never trust an id supplied by the caller.
throw new DomainError('FORBIDDEN', 'Plan does not belong to this facility.');
}
if (plan.deletedAt !== null) {
throw new DomainError('PLAN_NOT_AVAILABLE', 'This plan is no longer bookable.');
}
if (plan.checkInConfigId && plan.checkInConfigId !== input.checkInConfigId) {
// Configuration must be derived, not accepted from the request body.
input = { ...input, checkInConfigId: plan.checkInConfigId };
}
return reservations.insert(input, ctx);
}The last branch deserves emphasis: a related bug bound administrator-created bookings to another plan's check-in configuration because the configuration id arrived in the request. Identifiers that determine which door opens should be derived server-side from the authoritative record, never echoed back from the client.
3. Authenticate at the point of commitment
The check-in host application was restructured so that submitting the guest register is the check-in. Previously, authentication happened early and the flow could fall back to a lower-trust entrance screen after a failure — which meant a refused reservation check-in could degrade into an anonymous walk-in.
The fix was to model the flow as an explicit finite state machine where the authenticated session is part of the state's type, not a flag consulted later.
interface FacilitySession {
readonly sessionId: string;
readonly subjectId: string;
readonly facilityId: string;
readonly issuedAt: string;
}
type CheckInState =
| { step: 'entrance' }
| { step: 'search' }
| { step: 'confirm'; reservationId: string }
| { step: 'authenticate'; reservationId: string }
| { step: 'register'; reservationId: string; session: FacilitySession }
| { step: 'failed'; reservationId: string; reason: RefusalReason }
| { step: 'completed'; reservationId: string; checkInId: string };
type CheckInEvent =
| { type: 'SEARCH' }
| { type: 'SELECT'; reservationId: string }
| { type: 'CONFIRM' }
| { type: 'AUTHENTICATED'; session: FacilitySession }
| { type: 'SUBMIT_REGISTER'; register: GuestRegister }
| { type: 'REFUSED'; reason: RefusalReason };Because only the register state carries a session, the function that performs the privileged side effect cannot be reached without one. The compiler, not a code review, enforces the ordering.
export function reduce(state: CheckInState, event: CheckInEvent): CheckInState {
switch (state.step) {
case 'confirm':
if (event.type === 'CONFIRM') {
return { step: 'authenticate', reservationId: state.reservationId };
}
break;
case 'authenticate':
if (event.type === 'AUTHENTICATED') {
return {
step: 'register',
reservationId: state.reservationId,
session: event.session,
};
}
if (event.type === 'REFUSED') {
// Stay on the reservation. Never drop to 'entrance'.
return {
step: 'failed',
reservationId: state.reservationId,
reason: event.reason,
};
}
break;
default:
break;
}
throw new InvalidTransitionError(state.step, event.type);
}Three properties fall out of this design:
- No implicit downgrade. A refusal lands in
failedwith the reason visible to the operator, instead of silently returning to the walk-in entrance where a different, weaker policy applies. - Unauthenticated completion is unrepresentable.
completeCheckInaccepts onlyExtract<CheckInState, { step: 'register' }>. - Unknown transitions throw. A machine that ignores unexpected events accumulates undefined behaviour; one that throws surfaces the bug during integration testing.
export async function completeCheckIn(
state: Extract<CheckInState, { step: 'register' }>,
register: GuestRegister,
deps: CheckInDeps,
): Promise<CheckInState> {
// state.session is statically guaranteed to exist.
await deps.repo.saveGuestRegister(state.reservationId, register, state.session);
const result = await deps.api.checkIn({
reservationId: state.reservationId,
sessionId: state.session.sessionId,
});
return { step: 'completed', reservationId: state.reservationId, checkInId: result.id };
}Note the ordering: the register is persisted before the check-in is completed. If the key issuance succeeds but the compliance record is lost, you have a door that opened with no audit trail — the worst possible failure for a regulated facility.
4. Credentials are leases, not grants
Several fixes converged on one idea: a door key is a lease scoped to a subject, a reason, and a time window — and it must be revoked when the reason disappears.
interface DoorKeyLease {
readonly keyId: string;
readonly lockIds: string[];
readonly subjectId: string; // who may use it
readonly reservationId: string; // why it exists
readonly validFrom: string; // ISO 8601, resolved in facility timezone
readonly validUntil: string;
readonly revokedAt: string | null;
}
export function isUsable(lease: DoorKeyLease, now: Date): boolean {
if (lease.revokedAt !== null) return false;
const t = now.getTime();
return t >= Date.parse(lease.validFrom) && t < Date.parse(lease.validUntil);
}The revocation must be driven by the status transition itself, not by a nightly job or by the UI:
const TERMINATING_STATUSES = new Set([
'cancelled',
'refused',
'no_show',
'checked_out',
]);
export async function applyStatusUpdate(
reservation: Reservation,
next: ReservationStatus,
deps: StatusDeps,
): Promise<Reservation> {
if (!isAllowedTransition(reservation.status, next)) {
throw new DomainError('INVALID_TRANSITION', `${reservation.status} -> ${next}`);
}
const updated = await deps.repo.updateStatus(reservation.id, next);
if (TERMINATING_STATUSES.has(next)) {
// Idempotent: revoking an already-revoked lease is a no-op.
await deps.keys.revokeByReservation(reservation.id, { reason: `status:${next}` });
}
await deps.audit.record({
kind: 'reservation.status_changed',
reservationId: reservation.id,
from: reservation.status,
to: next,
actorId: deps.actorId,
at: new Date().toISOString(),
});
return updated;
}Two details that are easy to get wrong:
- Revocation must be idempotent. Status updates arrive from webhooks, admin actions, and PMS synchronisation. Treat double-revocation as success, not as an error worth retrying forever.
- Display must match enforcement. A related fix kept the key visible in the client until the booked slot actually opened. If the UI says "your key is ready" while the lease says
validFromis two hours away, you have manufactured a support ticket — and taught users to distrust the lock.
5. Time is a validation dimension, not a display concern
Check-in search was offering reservations whose checkout had already passed. Expired records are not merely stale UI; selecting one is an attempt to open a door outside its authorized window.
Filter at the query layer so the invalid option never reaches a human:
SELECT r.id, r.guest_name, r.check_in_at, r.check_out_at
FROM reservations r
WHERE r.facility_id = $1
AND r.status = 'confirmed'
AND r.check_in_at - make_interval(hours => $2) <= now()
AND r.check_out_at > now()
ORDER BY r.check_in_at
LIMIT 50;And keep timezone resolution on the server. Device clocks and device timezones are attacker-controlled input; a facility's "today" is a property of the facility, not of the phone standing in its lobby.
6. Validate collections, not just scalars
The guest-register work introduced per-room adult and child counts along with a list of guest records. Scalar validation (adults >= 1) is not enough — the relationship between fields is where the invariant lives. One fix explicitly rejected a single-room guests array that was empty or carried extra entries.
import { z } from 'zod';
const GuestSchema = z.object({
name: z.string().trim().min(1).max(100),
birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
nationality: z.string().length(2).optional(),
});
export const RoomOccupancySchema = z
.object({
roomId: z.string().uuid(),
adults: z.number().int().min(1).max(20),
children: z.number().int().min(0).max(20),
guests: z.array(GuestSchema).max(40),
})
.superRefine((value, ctx) => {
const declared = value.adults + value.children;
if (value.guests.length !== declared) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['guests'],
message: `Expected ${declared} guest record(s), received ${value.guests.length}.`,
});
}
});
export const StaySchema = z
.object({ rooms: z.array(RoomOccupancySchema).min(1) })
.superRefine((value, ctx) => {
const ids = value.rooms.map((r) => r.roomId);
if (new Set(ids).size !== ids.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['rooms'],
message: 'Duplicate roomId in stay.',
});
}
});
export type StayOccupancy = z.infer<typeof StaySchema>;The same schema runs in the booking client and in the check-in endpoint. The client version produces friendly field errors; the server version is the one that decides. Re-validating at the boundary where the lock opens — not only where the form is rendered — is what makes the gate real.
The database carries the invariant too, so that legacy import paths cannot bypass the service layer:
ALTER TABLE reservations
ADD COLUMN declared_adults smallint,
ADD COLUMN declared_children smallint,
ADD CONSTRAINT declared_counts_nonneg
CHECK (
(declared_adults IS NULL AND declared_children IS NULL)
OR (declared_adults >= 1 AND declared_children >= 0)
);Nullable columns were a deliberate choice, documented in an ADR: pre-existing PMS reservations have no declared count, and NULL means "unknown" rather than "zero". Encoding unknown distinctly from none prevents a later gate from treating missing data as a satisfied requirement — the same fail-closed principle from section 1, applied to schema design.
7. Writes that must not be lost: bounded retry with idempotency
Guest counts were occasionally lost when a downstream write failed transiently. The fix was a retry, but a retry is only safe when the operation is idempotent or carries an idempotency key.
interface RetryOptions {
attempts?: number;
baseDelayMs?: number;
isRetryable: (error: unknown) => boolean;
}
export async function withRetry<T>(
fn: (attempt: number) => Promise<T>,
{ attempts = 3, baseDelayMs = 100, isRetryable }: RetryOptions,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt === attempts || !isRetryable(error)) throw error;
const jitter = Math.random() * baseDelayMs;
await sleep(baseDelayMs * 2 ** (attempt - 1) + jitter);
}
}
throw lastError;
}Usage keeps the retry predicate narrow — never retry a 409 Conflict or a validation failure, because repeating a rejected request only amplifies load:
await withRetry(
() => repo.upsertGuestCounts(reservationId, counts, { idempotencyKey }),
{
isRetryable: (e) =>
e instanceof TransientDbError || (e instanceof HttpError && e.status >= 500),
},
);For privileged operations — issuing or revoking a key — the retry predicate should be even stricter, and every attempt should be recorded in the audit log so that a duplicate physical credential can be traced after the fact.
8. Push the trust boundary down to the database
Application-layer authorization is one layer. A security fix in this cycle removed the remaining SECURITY DEFINER views from the public schema and added a CI guard that fails the build if any new one appears.
A definer-mode view executes with the privileges of its owner, which silently bypasses row-level security. Invoker mode makes the caller's policies apply:
ALTER VIEW public.reservation_summary SET (security_invoker = true);
ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;
CREATE POLICY reservations_tenant_isolation ON reservations
USING (facility_id = current_setting('app.facility_id', true)::uuid);The guard that keeps it that way:
SELECT c.relname AS offending_view
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'v'
AND n.nspname = 'public'
AND NOT COALESCE(
array_to_string(c.reloptions, ',') LIKE '%security_invoker=true%',
false
);If that query returns any row, the pipeline fails. Security properties that are not asserted in CI decay within a few sprints.
The ratchet pattern
A companion guard counts call sites of a runtime assertion and refuses to let the count drop:
#!/usr/bin/env bash
set -euo pipefail
MIN_GUARDS=36
count=$(grep -R --include='*.ts' -o 'assertUnitLockSource(' src | wc -l | tr -d ' ')
if [ "$count" -lt "$MIN_GUARDS" ]; then
echo "unit-lock-source guards dropped: $count < $MIN_GUARDS" >&2
exit 1
fi
echo "unit-lock-source guards: $count (min $MIN_GUARDS)"A ratchet is a crude metric, deliberately so. It cannot prove correctness, but it makes the silent removal of a safety check impossible — the removal now requires editing the threshold, which is visible in review.
9. Tests that follow the risk
Two testing commits are worth highlighting because they address failure modes that are invisible in a green build.
Stale mocks. A test suite that asserts against a hand-written SDK mock keeps passing after the real SDK changes shape. The mitigation is to derive mocks from the published types so a signature change becomes a compile error:
import type { CheckInClient } from '@unlockos/sdk';
export function createCheckInClientMock(
overrides: Partial<CheckInClient> = {},
): CheckInClient {
// satisfies forces the mock to stay structurally compatible.
return {
lookupReservation: vi.fn(async () => stubReservation()),
checkIn: vi.fn(async () => ({ id: 'checkin_1' })),
saveGuestRegister: vi.fn(async () => undefined),
...overrides,
} satisfies CheckInClient;
}Alternate entry paths. The guest register was enforced in the main flow but could be skipped via a direct reservation link that refetched state. Any privileged flow with more than one entry point needs a test per entry point:
describe.each(['search', 'direct-link', 'qr-scan'] as const)('entry=%s', (entry) => {
it('refuses to complete check-in without a saved guest register', async () => {
const ctx = await startCheckIn({ entry, reservation: gatedReservation });
await expect(ctx.submit({ register: null })).rejects.toThrow('GUEST_REGISTER_REQUIRED');
expect(ctx.deps.keys.issue).not.toHaveBeenCalled();
});
});The final assertion is the important one: it verifies not just that the request failed, but that no credential was issued as a side effect.
Summary
A checklist distilled from this cycle, applicable to any system that controls physical access:
- Default to deny. Empty, missing, or malformed configuration resolves to "no permission".
- Distinguish unknown from none.
NULLmust never satisfy a requirement check. - Enforce on the server. Hiding a control is a UX decision; rejecting the request is the security control.
- Derive, don't echo. Identifiers that determine which lock opens come from the authoritative record, not the request body.
- Model flows as state machines. Put the session in the state type so privileged transitions are unreachable without authentication.
- Never downgrade silently. A failed high-trust path shows a refusal; it does not fall back to a lower-trust path.
- Treat credentials as leases. Bind to subject, reason, and time window; revoke idempotently on every terminating status transition.
- Validate relationships, not just fields. Cross-field and collection invariants are where real bugs hide.
- Retry only what is safe to retry. Bounded attempts, narrow predicates, idempotency keys, audit entries.
- Assert your security properties in CI. Invoker-mode views, guard-count ratchets, and per-entry-point tests keep yesterday's fix from quietly disappearing.
None of these require a new framework. They require that each decision point be written down once, enforced at the boundary that matters, and defended by a test that fails loudly.