Defense in Depth for Smart Locks: RLS, Tokens, and State
Introduction
A smart lock SDK is not a CRUD app. Every row we let a client write, every token we let expire ungracefully, and every state transition we allow to run backwards can translate into a physical door opening for the wrong person — or staying shut for the right one.
This article distills a sprint's worth of hardening work into patterns you can apply to any security-critical system: locking down the data layer, keeping privileged operations server-side, making credential expiry a normal state instead of an error, and designing notification/billing state machines that refuse to travel backwards in time.
1. The Database Is the Last Line of Defense
When a browser talks directly to Postgres through a data API, your application code is advice, not enforcement. The only thing that actually stops a crafted request is Row Level Security and grants.
Three recurring failure modes we found and fixed:
1.1 The "authenticated-open" policy
The most common anti-pattern is a policy that reads as USING (auth.role() = 'authenticated'). That grants every logged-in user of every tenant access to every row. It passes tests, because tests usually only have one tenant.
-- ANTI-PATTERN: any authenticated user of any facility can read/write.
create policy plan_groups_rw on public.plan_groups
for all to authenticated
using (true) with check (true);Rebuild it claims-based, so authorization is derived from the verified JWT rather than from row contents the caller controls:
drop policy if exists plan_groups_rw on public.plan_groups;
create policy plan_groups_select on public.plan_groups
for select to authenticated
using (facility_id = (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid);
create policy plan_groups_write on public.plan_groups
for all to authenticated
using (
facility_id = (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid
and (auth.jwt() -> 'app_metadata' ->> 'role') in ('facility_owner', 'facility_admin')
)
with check (
facility_id = (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid
and (auth.jwt() -> 'app_metadata' ->> 'role') in ('facility_owner', 'facility_admin')
);Two details that matter:
WITH CHECKis not optional.USINGfilters what you can see;WITH CHECKconstrains what you can write. Without it, a user can move a row into another tenant.- Split read from write. Viewing a plan group and mutating it are different privileges. Merging them into
FOR ALLguarantees the write policy will be as loose as the read policy.
1.2 SECURITY DEFINER functions are a bypass by design
A SECURITY DEFINER RPC runs with the owner's privileges — it deliberately steps over RLS. If anon or authenticated has EXECUTE on it, you have published a privilege-escalation endpoint.
-- Default-deny EXECUTE for everything, then grant deliberately.
revoke execute on all functions in schema public from anon, authenticated;
grant execute on function public.get_public_facility_summary(uuid) to anon, authenticated;
-- And put the authorization check *inside* the definer function.
create or replace function public.soft_delete_plan(p_plan_id uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare v_facility uuid;
begin
select facility_id into v_facility from plans where id = p_plan_id;
if v_facility is null then
raise exception 'plan not found' using errcode = 'P0002';
end if;
if v_facility <> (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid then
raise exception 'forbidden' using errcode = '42501';
end if;
update plans set deleted_at = now() where id = p_plan_id;
end;
$$;Note set search_path = public. Without it, a definer function can be tricked into resolving a table or operator from a schema the caller controls — a classic escalation vector.
1.3 Column-level scoping for shared tables
Some tables are legitimately written by both the client and the server. A check-in record, for example: the front end needs to flip a "key displayed" flag, but it must never be able to write payment_status, issued_key_id, or checked_out_at.
RLS operates on rows; grants operate on columns. Use both.
revoke insert, update on public.check_ins from authenticated;
grant update (key_shown_at, key_view_count, guest_acknowledged_at)
on public.check_ins to authenticated;
create policy check_ins_client_update on public.check_ins
for update to authenticated
using (guest_user_id = auth.uid())
with check (guest_user_id = auth.uid());Everything else — the columns that decide whether a door opens or money moves — is written only by the service role from an Edge Function. The rule of thumb:
If a column participates in an authorization or settlement decision, the client must not be able to write it. Ever.
The same logic applies to storage buckets: scope object-write policies to a path prefix that contains the tenant id, so one facility cannot overwrite another facility's plan images.
2. Privileged Operations Belong Behind the Server
Refunds are the canonical example. A refund endpoint that accepts a paymentIntentId and calls the payment provider is an unauthenticated money-drain if it is reachable from a browser.
Two hardening moves:
- Gate the refund path on the service role — verify the caller's JWT role claim inside the function, not just at the gateway.
- Require the refund target to be reachable from a reservation the caller owns, so a leaked id is not enough.
export async function handleRefund(req: Request): Promise<Response> {
const claims = await verifyJwt(req.headers.get('authorization'));
if (claims?.role !== 'service_role') {
return json({ error: 'forbidden', code: 'REFUND_REQUIRES_SERVICE_ROLE' }, 403);
}
const { reservationId, amount } = RefundInput.parse(await req.json());
// The PaymentIntent is derived from the reservation, never taken from input.
const payment = await db.findPaymentByReservation(reservationId);
if (!payment) {
return json({ error: 'not_found', code: 'REFUND_TARGET_UNRESOLVED' }, 404);
}
if (amount > payment.capturedAmount) {
return json({ error: 'invalid', code: 'REFUND_EXCEEDS_CAPTURE' }, 422);
}
return json(await provider.refund(payment.intentId, amount));
}Identifier normalization is a security concern
We found a bug where refund reconciliation only recognized one of the two shapes a provider returns for a PaymentIntent reference (a bare id versus a nested object/expanded field). Every legitimate refund was rejected. The failure was closed rather than open, which is the right direction — but the lesson generalizes:
type IntentRef = string | { id: string } | { payment_intent: string | { id: string } };
export function normalizeIntentId(ref: IntentRef | null | undefined): string | null {
if (!ref) return null;
if (typeof ref === 'string') return ref;
if ('id' in ref && typeof ref.id === 'string') return ref.id;
if ('payment_intent' in ref) return normalizeIntentId(ref.payment_intent);
return null;
}Whenever you compare identifiers that cross a trust boundary, normalize both sides through a single tested function. Ad-hoc === on polymorphic payloads produces either false rejects (outage) or false accepts (breach).
3. Test-Only Bypasses Must Not Survive the Build
Every codebase eventually grows a if (isTestUser) return true; inside a permission check. It is harmless in CI and catastrophic in production.
Make the bypass statically removable, then make its reintroduction a CI failure:
export function canManageFacility(user: User, facilityId: string): boolean {
// `import.meta.env.DEV` is a compile-time constant; the whole branch is
// dead-code-eliminated from the production bundle.
if (import.meta.env.DEV && user.email?.endsWith('@e2e.test')) {
return true;
}
return user.roles.some(
(r) => r.facilityId === facilityId && MANAGE_ROLES.has(r.name),
);
}The compiler guarantee is only half the control. Add a build-artifact assertion:
#!/usr/bin/env bash
set -euo pipefail
pnpm build
if grep -rqE "e2e\.test|PERMISSION_BYPASS" dist/assets/*.js; then
echo "FAIL: test-only permission bypass found in production bundle" >&2
exit 1
fi
echo "OK: no permission bypass markers in bundle"This is a general principle worth stating plainly: security properties you cannot assert in CI will regress. A grep over the shipped artifact is crude, fast, and impossible to argue with.
4. Credential Expiry Is a State, Not an Exception
Our lock vendor proxy had two distinct failure modes that both surfaced as "the key didn't appear":
- The proxy rejected an expired token instead of refreshing it.
- The vendor returned HTTP 200 with a body like
{"code":"E0000","message":"not logged in"}when a session went idle — so status-code-based error handling saw success.
The fix is to centralize session ownership in the proxy and to classify responses by semantics, not by transport status.
const REAUTH_CODES = new Set(['E0000', 'E0401', 'SESSION_EXPIRED']);
function needsReauth(status: number, body: unknown): boolean {
if (status === 401 || status === 403) return true;
if (status === 200 && isRecord(body) && typeof body.code === 'string') {
return REAUTH_CODES.has(body.code);
}
return false;
}
export async function callVendor<T>(path: string, init: RequestInit): Promise<T> {
let token = await tokenStore.get();
if (!token || tokenStore.isExpired(token, { skewSeconds: 60 })) {
token = await tokenStore.refresh(); // proactive: don't wait for a 401
}
let res = await fetch(path, withAuth(init, token));
let body = await res.json().catch(() => null);
if (needsReauth(res.status, body)) {
token = await tokenStore.refresh();
res = await fetch(path, withAuth(init, token)); // exactly one retry
body = await res.json().catch(() => null);
if (needsReauth(res.status, body)) {
throw new VendorAuthError('VENDOR_REAUTH_FAILED', { path });
}
}
if (!res.ok) throw new VendorError('VENDOR_CALL_FAILED', { path, status: res.status });
return body as T;
}Design notes:
- Proactive refresh with clock skew. Refreshing 60 seconds early removes an entire class of race conditions between token validation and request arrival.
- Exactly one retry. Unbounded retry on auth failure turns a credential problem into a self-inflicted DoS against the vendor.
- Single ownership. Once the proxy owns refresh, callers must not also refresh. We updated the render-layer tests to assert "proxy-owned refresh" explicitly, so a future contributor reintroducing client-side refresh breaks a test.
Make silent failures loud
A related class of bug: background pollers that fail quietly. If a detection poll or an anchor rollback fails, nobody notices until a guest is standing at a locked door. Two complementary controls:
- Surface the failure to staff in the operational UI, with a stable machine-readable code rather than a rendered English string.
- Alert on aggregate failure, e.g. "facility X has failed key issuance N times consecutively" — and include the facility name in the alert so on-call can act without a lookup.
export type KeyIssueFailure =
| { code: 'KEY_VENDOR_UNAVAILABLE'; retryable: true }
| { code: 'KEY_CONFIG_MISSING'; retryable: false }
| { code: 'KEY_WINDOW_EXPIRED'; retryable: false };
// UI maps `code` -> i18n message; logs/alerts key off `code`, never off the text.Stable codes are what let you localize the message, alert on the category, and write an assertion — all from the same value.
5. State Machines That Cannot Travel Backwards
Notifications, like locks, are state machines. Ours exposed two classic bugs.
5.1 Don't let unrelated transitions resurrect terminal states
A notification scheduled for a past due time was being reset to pending whenever the parent reservation's state changed. The result: a burst of notifications for events that had already happened.
The fix is to treat "due time is in the past" as an absorbing condition, evaluated in the guard rather than in the caller:
type NotificationState = 'pending' | 'sent' | 'skipped' | 'cancelled';
interface Notification {
state: NotificationState;
dueAt: Date;
}
const TERMINAL: ReadonlySet<NotificationState> = new Set(['sent', 'skipped', 'cancelled']);
export function reconcile(n: Notification, now: Date): NotificationState {
if (TERMINAL.has(n.state)) return n.state; // terminal is terminal
if (n.dueAt.getTime() <= now.getTime()) return 'skipped'; // never backfill
return 'pending';
}The generalizable rule: a reconciliation function must be idempotent and monotonic. Running it twice must not change the outcome, and it must never move a record toward a less-final state. If your reconciler can produce sent -> pending, it will eventually spam users or, in a lock system, re-issue a revoked key.
5.2 Close alerts into an actionable window
A detection-failure alert that fires for events from three weeks ago is noise. We bounded it:
const ACTIONABLE_WINDOW_MS = 24 * 60 * 60 * 1000;
export function shouldAlert(event: { detectedAt: Date; occurredAt: Date }): boolean {
const age = event.detectedAt.getTime() - event.occurredAt.getTime();
return age >= 0 && age <= ACTIONABLE_WINDOW_MS;
}The same guard also prevents backfill storms when a feature is first deployed against historical data — a surprisingly common production incident. Whenever you add a new alert over an existing table, ask: "what happens on the first run against the full history?"
5.3 Refuse impossible operations outright
Another instance from the same sprint: the admin UI allowed extending the validity of a revoked door key. Silently succeeding here means the UI claims access was extended while the lock disagrees.
export function assertExtendable(key: DoorKey): void {
if (key.status === 'revoked') {
throw new DomainError('KEY_REVOKED_NOT_EXTENDABLE', { keyId: key.id });
}
if (key.status === 'expired' && key.expiredAt < subDays(new Date(), 30)) {
throw new DomainError('KEY_TOO_OLD_TO_EXTEND', { keyId: key.id });
}
}And crucially: confirm the vendor-side change before claiming success in the UI. Optimistic UI is fine for a "like" button; it is not fine for physical access. When a reservation's time window moves, the key's validity window must move with it, and the UI must reflect the state the lock actually holds.
6. Time Math Is a Correctness Boundary
Three separate bugs in one sprint traced back to time handling. That is not a coincidence — time is where domain rules and machine representation collide.
6.1 Reset boundaries in the facility's timezone, not the server's
A daily price cap that resets at UTC midnight is wrong for every facility not in UTC, and the error is invisible until a guest is billed twice.
import { formatInTimeZone, toDate } from 'date-fns-tz';
export function dayKey(at: Date, timeZone: string): string {
return formatInTimeZone(at, timeZone, 'yyyy-MM-dd');
}
export function startOfFacilityDay(at: Date, timeZone: string): Date {
return toDate(`${dayKey(at, timeZone)}T00:00:00`, { timeZone });
}6.2 Truncate deliberately
A reset boundary computed from "now" carried milliseconds, so a window that should have been [00:00:00.000, 24:00:00.000) became [00:00:00.317, ...) — leaving a 317 ms hole that a request occasionally fell into.
export function truncateToSecond(d: Date): Date {
return new Date(Math.floor(d.getTime() / 1000) * 1000);
}If a boundary is part of a business rule, compute it from a calendar value, never from a timestamp you forgot to truncate. And always use half-open intervals [start, end) so adjacent windows can neither overlap nor gap.
6.3 Sign the window the price was computed for
A quote is only valid for the interval it was computed against. If the client can change the interval after the quote is issued, pricing is advisory. We made quotes binding by including the window in the signature:
import { createHmac, timingSafeEqual } from 'node:crypto';
export interface BindingQuote {
planId: string;
startAt: string; // ISO-8601 with offset
endAt: string;
amount: number;
currency: 'JPY';
issuedAt: string;
signature: string;
}
function payload(q: Omit<BindingQuote, 'signature'>): string {
return [q.planId, q.startAt, q.endAt, q.amount, q.currency, q.issuedAt].join('|');
}
export function signQuote(q: Omit<BindingQuote, 'signature'>, secret: string): BindingQuote {
const signature = createHmac('sha256', secret).update(payload(q)).digest('hex');
return { ...q, signature };
}
export function verifyQuote(q: BindingQuote, secret: string, now: Date): boolean {
const expected = createHmac('sha256', secret).update(payload(q)).digest();
const actual = Buffer.from(q.signature, 'hex');
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
return now.getTime() - Date.parse(q.issuedAt) <= 15 * 60 * 1000;
}The server recomputes the fee window from authoritative data anyway; the signature exists so that a mismatch is a detectable tampering event rather than a silent discrepancy. Use timingSafeEqual, and always include an expiry — an unbounded signed token is a replay primitive.
7. Tests That Actually Earn Trust
Not all tests are equal. Three categories carried disproportionate weight this sprint.
7.1 Contract tests at conversion boundaries
Most of our pricing bugs lived in the conversion layer between the database representation and the domain model — for example, a ¥0 plan returning null instead of a zero-amount quote, because the code used amount || null.
import { describe, expect, it } from 'vitest';
import { toFeeQuote } from './convert';
describe('toFeeQuote contract', () => {
it('preserves a zero-amount plan as a quote, not null', () => {
const quote = toFeeQuote({ planId: 'p1', amountMinor: 0, currency: 'JPY' });
expect(quote).not.toBeNull();
expect(quote?.amount).toBe(0);
});
it('rejects negative amounts instead of coercing them', () => {
expect(() => toFeeQuote({ planId: 'p1', amountMinor: -1, currency: 'JPY' }))
.toThrowError(/NEGATIVE_AMOUNT/);
});
});The general lesson: 0, '', and false are valid domain values. Any use of || on a numeric or boolean field is a latent bug. Prefer ??, and write an explicit test for the falsy-but-valid case at every boundary.
7.2 Production canaries for money and access paths
Unit tests prove your code is self-consistent. They cannot prove that production configuration, keys, and environment modes are correct. We added a canary that invokes the real pricing function in production with a fixed, known-answer case:
const CANARY_CASES = [
{ name: 'hourly-2h-weekday', input: FIXED_INPUT_A, expectedAmount: 2000 },
{ name: 'flat-plus-overtime', input: FIXED_INPUT_B, expectedAmount: 5500 },
] as const;
export async function runFeeCanary(): Promise<CanaryResult[]> {
return Promise.all(CANARY_CASES.map(async (c) => {
const started = Date.now();
const quote = await invokeFeeFunction(c.input);
return {
name: c.name,
ok: quote.amount === c.expectedAmount,
actual: quote.amount,
expected: c.expectedAmount,
latencyMs: Date.now() - started,
};
}));
}This is how we caught an entire class of environment bug: functions reading the wrong payment mode and always using test keys regardless of the organization's configured mode. No unit test can see that; a canary sees it in minutes. (Related guard: explicitly reject a live-prefixed key saved into a test-key field, at write time, with a clear validation error.)
7.3 Fix flaky tests as security work
A flaky integration test is worse than no test: it trains the team to re-run CI until green, which is exactly how a real regression ships. Two red unit tests were blocking a release, and a booking-flow integration test was intermittently failing. Both were treated as release blockers, not chores.
Flakiness almost always comes from ambient state — real clocks, shared fixtures, or unawaited effects:
import { afterEach, beforeEach, vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-01T09:00:00+09:00'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});If a test depends on Date.now(), freeze it. If it depends on ordering, assert on a sorted projection. If it depends on a network, make the boundary explicit and mock it — then cover the real boundary with a canary instead.
Summary
The patterns above compose into a checklist we now apply to every change that touches access, money, or state:
| Layer | Control |
|---|---|
| Database | RLS with both USING and WITH CHECK; claims-based, never authenticated = true |
| Functions | Default-deny EXECUTE; authorize inside SECURITY DEFINER; pin search_path |
| Columns | Grant writes only on non-privileged columns; settlement/key columns are service-role only |
| Build | Test bypasses behind import.meta.env.DEV and a CI grep over the shipped bundle |
| Credentials | Proactive refresh with skew, semantic (not status-code) error classification, exactly one retry |
| State | Idempotent, monotonic reconcilers; terminal states are absorbing; bounded actionable windows |
| Time | Facility-timezone day boundaries, deliberate truncation, half-open intervals, signed quote windows |
| Tests | Contract tests on falsy-but-valid values, production canaries, zero tolerance for flakes |
None of these individually is clever. The value is in the layering: an attacker or a bug has to defeat the JWT claim check, the row policy, the column grant, the server-side derivation, and the state guard — and if it somehow gets through, the canary and the alerting surface it within minutes rather than at the moment someone is standing in front of a door that will not open.