UnlockOS Developers
← Back to blog
🔐

Fail-Closed Access Control in a Smart Lock Check-In Flow

Oct 12, 2026→Oct 18, 2026
9 min
130 commits
Depth 8/10
securitystate-machinetypescriptvalidationtesting

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 true because of data that explicitly says true. Missing, empty, or malformed configuration always resolves to false.

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:

  1. No implicit downgrade. A refusal lands in failed with the reason visible to the operator, instead of silently returning to the walk-in entrance where a different, weaker policy applies.
  2. Unauthenticated completion is unrepresentable. completeCheckIn accepts only Extract<CheckInState, { step: 'register' }>.
  3. 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 validFrom is 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:

  1. Default to deny. Empty, missing, or malformed configuration resolves to "no permission".
  2. Distinguish unknown from none. NULL must never satisfy a requirement check.
  3. Enforce on the server. Hiding a control is a UX decision; rejecting the request is the security control.
  4. Derive, don't echo. Identifiers that determine which lock opens come from the authoritative record, not the request body.
  5. Model flows as state machines. Put the session in the state type so privileged transitions are unreachable without authentication.
  6. Never downgrade silently. A failed high-trust path shows a refusal; it does not fall back to a lower-trust path.
  7. Treat credentials as leases. Bind to subject, reason, and time window; revoke idempotently on every terminating status transition.
  8. Validate relationships, not just fields. Cross-field and collection invariants are where real bugs hide.
  9. Retry only what is safe to retry. Bounded attempts, narrow predicates, idempotency keys, audit entries.
  10. 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.

Key Insights

1
Security

Empty configuration must resolve to deny

Treating an unset or empty permission scope as 'no restriction' silently grants universal access. Permission predicates should only return true because of data that explicitly says true, and the test table should enumerate the null/empty cases.

2
State Machine

Put the session in the state type

Modelling the check-in flow as a discriminated union where only the authenticated state carries a FacilitySession makes unauthenticated completion unrepresentable at compile time, and ensures a refusal never degrades into a lower-trust walk-in path.

3
Reliability

Door credentials are leases bound to subject, reason and window

Keys should carry subjectId, reservationId, validFrom and validUntil, be evaluated against a server-resolved facility clock, and be revoked idempotently by the status transition itself rather than by a background job or the UI.

4
Validation

Cross-field invariants belong in the schema and the database

Guest counts and guest record arrays must agree with each other; superRefine validators run on both client and server, while CHECK constraints and nullable 'unknown' semantics stop legacy import paths from bypassing the service layer.

5
Testing

Assert security properties in CI and test every entry point

A SQL guard that fails the build on any SECURITY DEFINER public view, a guard-count ratchet, type-derived mocks, and per-entry-point tests that assert no credential was issued on failure prevent silent regression of past fixes.