UnlockOS Developers
← Back to blog
🔐

Closing Bypass Windows: Races, Idempotency & Token Gates

Jun 15, 2026Jun 21, 2026
9 min
172 commits
Depth 8/10
securitystate-machineerror-handlingtestingtypescript

Closing Bypass Windows: Races, Idempotency & Token Gates

In a system that opens physical doors, a bug is not a cosmetic glitch — it is either a locked-out guest or an unauthorized entry. This post walks through a cluster of hardening changes we shipped recently across check-in/out, membership activation, external PMS integrations, and our test suite, and generalizes them into patterns you can apply to any access-control system.

The unifying theme: every multi-step flow has windows where the system is in an intermediate state, and those windows are where both races and bypasses live.


1. Terminal transitions must be idempotent, not "first writer wins"

Check-out can be triggered from at least three places: the guest taps a button, an auto-checkout job fires at the scheduled end time, and an operator forces it from the admin console. Naive implementations do this:

// Fragile: read-then-write with a window between the two
const stay = await getStay(stayId);
if (stay.status !== 'checked_in') {
  throw new Error('Invalid state');
}
await updateStay(stayId, { status: 'checked_out', checkedOutAt: new Date() });

Two problems. First, the read-check-write window lets the auto-checkout job and the user action interleave, producing a duplicated side effect (double audit entry, double billing signal). Second, the loser of the race gets a hard error — so a guest who taps "Check out" one second after the scheduler already did sees a scary failure for an operation that actually succeeded.

Push the guard into the database, and treat "already terminal" as success:

update stays
set    status         = 'checked_out',
       checked_out_at = coalesce(checked_out_at, now()),
       checkout_source= coalesce(checkout_source, $2)
where  id = $1
  and  status in ('checked_in', 'checked_out')
returning id, status, checked_out_at, checkout_source;

coalesce makes the write idempotent: the first transition stamps the timestamp and source, later ones are no-ops that still return the row. The where clause is the state guard — it is evaluated atomically with the write, so concurrent callers cannot both observe checked_in.

The service layer then distinguishes "I performed it" from "it was already done", but reports both as success:

export type CheckoutResult =
  | { ok: true; performed: boolean; checkedOutAt: string }
  | { ok: false; code: 'STAY_NOT_CHECKED_IN' | 'STAY_NOT_FOUND' };

export async function checkout(
  stayId: string,
  source: 'guest' | 'auto' | 'operator'
): Promise<CheckoutResult> {
  const row = await db.oneOrNone(CHECKOUT_SQL, [stayId, source]);
  if (!row) {
    const exists = await db.oneOrNone('select 1 from stays where id = $1', [stayId]);
    return { ok: false, code: exists ? 'STAY_NOT_CHECKED_IN' : 'STAY_NOT_FOUND' };
  }
  return { ok: true, performed: row.checkout_source === source, checkedOutAt: row.checked_out_at };
}

Rule of thumb: for terminal states, the correct semantic is convergent, not exclusive. Retries, double taps, and scheduler overlap should all converge on the same row.


2. A terminal state must revoke its credentials, not just flip a flag

The dangerous half of check-out is not the status column — it is the access credential that is still sitting in the guest's UI. If the key card, PIN, or "extend stay" button survives the transition, you have effectively granted post-checkout access.

We made the credential a derived value of the state machine rather than a separate piece of cached state:

interface StaySnapshot {
  status: 'reserved' | 'checked_in' | 'checked_out' | 'cancelled';
  accessWindow: { start: string; end: string };
}

const ACTIVE_STATUSES = new Set(['checked_in']);

export function visibleCredentials(stay: StaySnapshot, now: Date): CredentialView {
  const withinWindow = now >= new Date(stay.accessWindow.start)
                    && now <= new Date(stay.accessWindow.end);
  const active = ACTIVE_STATUSES.has(stay.status) && withinWindow;
  return {
    showKeyCard: active,
    showCheckoutAction: active,
    showExtendAction: active,
    // Never render a stale credential from a previous render pass.
    credential: active ? stay.credential : null,
  };
}

Two invariants fall out of this:

  1. Single source of truth. The UI never holds a "currentKey" that outlives the state it was derived from. On every state refresh, stale cards disappear automatically.
  2. Time is part of the state. Reservation end is as much a terminal condition as an explicit checkout. Hiding the key at accessWindow.end requires no job to run on time.

Server-side revocation still matters — the client is not a security boundary — but deriving the view from state removes an entire class of "ghost key" bugs.


3. Multi-step onboarding is a state machine, and every skipped edge is a bypass

We introduced an "apply-first" membership flow: a prospect submits an application, staff reviews it, and only after approval can the applicant pay and get activated. That is four states and a strict edge set — and the first review found two ways to skip edges:

  • A client could call the activation endpoint directly and jump pending_review → active, skipping approval.
  • A race between payment webhook and manual approval could activate an unpaid subscription.

The fix is to write the transition table down explicitly and enforce it server-side on every action:

export type ApplicationStatus =
  | 'draft'
  | 'pending_review'
  | 'approved'
  | 'rejected'
  | 'payment_pending'
  | 'active'
  | 'cancelled';

const TRANSITIONS = {
  draft:           ['pending_review', 'cancelled'],
  pending_review:  ['approved', 'rejected', 'cancelled'],
  approved:        ['payment_pending', 'cancelled'],
  payment_pending: ['active', 'cancelled'],
  active:          ['cancelled'],
  rejected:        [],
  cancelled:       [],
} as const satisfies Record<ApplicationStatus, readonly ApplicationStatus[]>;

export function assertTransition(from: ApplicationStatus, to: ApplicationStatus): void {
  const allowed: readonly ApplicationStatus[] = TRANSITIONS[from];
  if (!allowed.includes(to)) {
    throw new DomainError('INVALID_TRANSITION', { from, to });
  }
}

satisfies keeps the table exhaustive: add a status to the union and TypeScript fails the build until you declare its outgoing edges. That is type safety doing real security work — you cannot silently introduce an unreachable or unguarded state.

But in-process validation is not enough under concurrency. The authoritative guard belongs in the same statement as the write:

update membership_applications
set    status = 'active', activated_at = now()
where  id = $1
  and  status = 'payment_pending'
  and  exists (
         select 1 from payments p
         where  p.application_id = membership_applications.id
           and  p.status = 'succeeded'
       )
returning id;

If zero rows come back, the activation did not happen — either the state was wrong or no payment succeeded. No amount of webhook reordering can produce an unpaid active membership.


4. Never let a stale client-side artifact satisfy an auth step

A related bug pattern, found in the guest booking flow: a payment-state object left over from an abandoned attempt let a guest reach the confirmation step without completing email + OTP verification. The flow's guard was effectively:

if (paymentState) {
  goToConfirmation(); // OTP already done... allegedly
}

The object existed, so the check passed. Truthiness is not authentication.

Two corrections, both worth generalizing:

interface VerifiedSession {
  email: string;
  verifiedAt: number;   // epoch ms of successful OTP
  facilityId: string;
  nonce: string;        // ties the session to this booking attempt
}

const OTP_TTL_MS = 15 * 60 * 1000;

export function isVerified(s: VerifiedSession | null, ctx: BookingContext, now = Date.now()): boolean {
  if (!s) return false;
  if (s.facilityId !== ctx.facilityId) return false;   // no cross-facility reuse
  if (s.nonce !== ctx.nonce) return false;             // no cross-attempt reuse
  return now - s.verifiedAt < OTP_TTL_MS;              // no infinite validity
}

And: clear the artifact when the flow resets. Any time the user changes email, abandons payment, or restarts the booking, the previous verification state is destroyed, not reused. Sensitive flow state should be scoped to an attempt id and garbage-collected aggressively, because a leftover object is a leftover authorization.

Of course, the server re-verifies too. Client-side gating is UX; server-side gating is security. Both had to be fixed.


5. Authorization checks need the full subject, or elevated roles silently fail

An integrations hub was hiding admin-only cards from Platform Admins. The cause was mundane and instructive: the feature-flag evaluator was called with the facility context but without the user id, so the admin-bypass branch could never fire.

// Before: subject is incomplete, so role-based overrides are dead code
const enabled = await isEnabled('card_gcal', { facilityId });

// After: the evaluator receives the whole subject
const enabled = await isEnabled('card_gcal', { facilityId, userId, roles });

This failed "safe" (too little access) this time. The mirror-image bug — a policy function that defaults to allow when context is missing — fails open. So make the context non-optional at the type level:

export interface PolicySubject {
  userId: string;        // required, not string | undefined
  facilityId: string;
  roles: readonly Role[];
}

export async function isEnabled(flag: FlagKey, subject: PolicySubject): Promise<boolean> {
  const rule = await loadRule(flag, subject.facilityId);
  if (!rule) return false;                          // default-deny
  if (subject.roles.includes('platform_admin')) return true;
  return rule.enabledFor(subject);
}

Making the subject a required, fully-populated struct means the compiler catches every call site that forgot to thread identity through. Optional auth context is a latent vulnerability.


6. Move privileged reads behind a definer function with explicit claims

Facility settings were being read by joining tables directly from the client. That works only as long as every row-level policy is perfect on every table involved — a wide surface. We replaced it with a single database function that encapsulates the check:

create or replace function get_facility_settings(p_facility_id uuid)
returns table (facility_id uuid, address text, contact_email text, contact_phone text)
language plpgsql
security definer
set search_path = public
as $$
begin
  if not has_facility_claim(auth.uid(), p_facility_id) then
    raise exception 'forbidden' using errcode = '42501';
  end if;
  return query
    select f.id, f.address, f.contact_email, f.contact_phone
    from   facilities f
    where  f.id = p_facility_id;
end;
$$;

Three details that matter for security definer functions:

  • set search_path = public prevents search-path hijacking by a caller-controlled schema.
  • The authorization check is the first statement, and it raises rather than returning an empty set, so callers cannot confuse "denied" with "empty".
  • The projection is explicit. No select * means adding a sensitive column later does not silently widen the response.

7. Token-gate any resource you hand to an external system

Inbound PMS integrations need to deliver a guest key without the guest ever logging into our app. That is exactly the kind of convenience that turns into an enumeration hole if the delivery URL is just /key/<reservationId>.

We issue a short-lived, scope-bound, signed token instead:

interface KeyDeliveryClaims {
  sub: string;        // reservation id
  fac: string;        // facility id
  scope: 'key:read';  // single capability
  exp: number;        // seconds since epoch
  jti: string;        // for replay tracking / revocation
}

export async function verifyDeliveryToken(raw: string, secret: CryptoKey): Promise<KeyDeliveryClaims> {
  const [body, sig] = raw.split('.');
  const expected = await hmacSha256(body, secret);
  if (!timingSafeEqual(decodeBase64Url(sig), expected)) {
    throw new AuthError('INVALID_TOKEN');
  }
  const claims = JSON.parse(decodeText(decodeBase64Url(body))) as KeyDeliveryClaims;
  if (claims.scope !== 'key:read') throw new AuthError('INVALID_SCOPE');
  if (claims.exp * 1000 < Date.now()) throw new AuthError('TOKEN_EXPIRED');
  if (await isRevoked(claims.jti)) throw new AuthError('TOKEN_REVOKED');
  return claims;
}

Checklist for this pattern:

  • Constant-time comparison for signatures — never === on the raw string.
  • Narrow scope: the token can read one reservation's key, nothing else. It cannot cancel, extend, or list.
  • Short expiry plus a jti so a leaked link can be killed without rotating the signing secret.
  • Verify before parse-trusting: signature first, claims second.
  • Every redemption writes an audit row (token jti, reservation, ip, outcome), because for physical access the question "who opened this door and how were they authorized?" must always have an answer.

8. Sanitize third-party content at the boundary

Calendar integrations import descriptions written by arbitrary external users. Those arrive as HTML. Rendering them anywhere near an operator console is a stored-XSS invitation, and even in a "safe" text node the markup is ugly noise.

We flatten on ingest rather than hoping every render site escapes correctly:

export function htmlToPlainText(input: string): string {
  return input
    .replace(/<\s*br\s*\/?\s*>/gi, '\n')
    .replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
    .replace(/<[^>]*>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/\n{3,}/g, '\n\n')
    .trim()
    .slice(0, MAX_NOTE_LENGTH);
}

Normalize once, at the trust boundary, and store the normalized form. Sanitizing at render time means N chances to forget; sanitizing at ingest means one. The length cap is part of the defense too — unbounded external input is a denial-of-service vector for anything that indexes or renders it.


9. Wall-clock correctness is a safety property

Several fixes in this batch were timezone bugs: recording windows stored as UTC instants but meant as JST wall-clock, plan availability day-of-week computed in the wrong zone, reservation timestamps rendered against the browser's locale instead of the facility's.

For access control, an off-by-one-day or off-by-nine-hours error is not cosmetic — it either grants entry outside the authorized window or locks out a legitimate guest.

The discipline we settled on:

// Store instants in UTC. Store the facility timezone alongside the entity.
interface AccessWindow {
  startUtc: string;   // ISO 8601 with Z
  endUtc: string;
  timezone: string;   // IANA, e.g. 'Asia/Tokyo'
}

// Convert only at the edges, never implicitly via the runtime default zone.
export function toFacilityWallClock(iso: string, timezone: string): string {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: timezone,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hour12: false,
  }).format(new Date(iso));
}

// Day-of-week rules must be evaluated in facility time, not UTC or device time.
export function facilityDayOfWeek(iso: string, timezone: string): number {
  const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short' })
    .formatToParts(new Date(iso));
  const weekday = parts.find((p) => p.type === 'weekday')!.value;
  return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday);
}

The rule: a bare Date in business logic is a bug waiting to happen. Any comparison that decides access must name its timezone explicitly.

The same reasoning drove a check-in change: instead of searching all reservations for a matching guest, search is now scoped to the set that is currently checkable, and when nothing matches we return the availableAt timestamp so the UI can say "check-in opens at 15:00" instead of "not found". Narrowing the query is both a better error message and a smaller data-exposure surface.


10. Errors: specific enough to act on, generic enough to be safe

Raw system errors leaked into guest-facing UIs in a few places — English stack-ish strings shown to Japanese users, and generic "Edge Function returned a non-2xx status code" messages that hid the actual validation failure from operators.

The pattern we standardized: a closed union of error codes on the wire, mapped to localized copy on the client.

export type CheckinErrorCode =
  | 'CHECKIN_PERIOD_ENDED'
  | 'CHECKIN_OUTSIDE_HOURS'
  | 'CHECKIN_INFO_INSUFFICIENT'
  | 'RESERVATION_NOT_FOUND'
  | 'UNKNOWN';

interface ApiError {
  code: CheckinErrorCode;
  // Structured, non-sensitive context for the message template.
  meta?: { availableAt?: string; missingFields?: string[] };
}

const MESSAGES: Record<CheckinErrorCode, (m?: ApiError['meta']) => string> = {
  CHECKIN_PERIOD_ENDED:      () => t('checkin.periodEnded'),
  CHECKIN_OUTSIDE_HOURS:     (m) => t('checkin.outsideHours', { at: m?.availableAt }),
  CHECKIN_INFO_INSUFFICIENT: (m) => t('checkin.infoInsufficient', { fields: m?.missingFields }),
  RESERVATION_NOT_FOUND:     () => t('checkin.notFound'),
  UNKNOWN:                   () => t('common.unexpectedError'),
};

export function toUserMessage(err: ApiError): string {
  return (MESSAGES[err.code] ?? MESSAGES.UNKNOWN)(err.meta);
}

Why this is a security property and not just polish:

  • The exhaustive Record means a new code cannot ship without copy; the compiler refuses. No more raw English fallthrough.
  • The wire format carries codes, not prose, so internal details (table names, SQL text, upstream vendor payloads) never reach the client.
  • Some conditions aren't errors at all. "Check-in period has ended" is an informational notice, not a red failure — mislabeling expected states as errors trains users to ignore real ones.
  • Meanwhile, on the operator side we deliberately surface the structured error body from the edge function instead of the transport-level message, because staff need to know which time slot conflicted.

Different audiences, different granularity, same structured source.


11. Tests that can actually fail

One review comment in this batch is worth framing: a test was asserting that a URL builder used the environment-aware base by checking that the source file contained a certain string — which a code comment satisfied. Permanently green, permanently useless.

// False-green: passes because a comment mentions the symbol
expect(sourceCode).toContain('resolveAppBase');

// Behavioral: fails if the environment wiring regresses
describe('buildGoPortalShortUrl', () => {
  it.each([
    ['production', 'https://go.example.io/s/AB12CD'],
    ['staging',    'https://st-go.example.io/s/AB12CD'],
    ['local',      'http://localhost:3000/s/AB12CD'],
  ])('resolves base for %s', (env, expected) => {
    expect(buildGoPortalShortUrl({ env, code: 'AB12CD' })).toBe(expected);
  });
});

Hardcoded production hostnames in shared builders are a real hazard: a staging QA flow that mints a production door link is a cross-environment access leak. The test must pin behavior per environment.

The second testing fix was an isolation race. RLS tests shared fixture facilities, so parallel cleanup in one test deleted rows another test was asserting on — producing flaky failures that teams learn to re-run instead of investigate. The remedy:

beforeEach(async () => {
  // Throwaway tenant per test: no shared mutable state, no cleanup ordering.
  ctx = await createThrowawayFacility({ prefix: `rls-${crypto.randomUUID()}` });
});

afterEach(async () => {
  await destroyFacilityCascade(ctx.facilityId);
});

it('denies cross-facility read of intents', async () => {
  const other = await createThrowawayFacility({ prefix: 'rls-other' });
  const client = await signInAs(ctx.memberUser);
  const { data, error } = await client.from('intents').select('*').eq('facility_id', other.facilityId);
  expect(error?.code).toBe('42501');
  expect(data).toBeNull();
  await destroyFacilityCascade(other.facilityId);
});

A flaky security test is worse than no test, because it teaches the team to ignore red. Isolation is what makes an RLS assertion trustworthy.


12. Keep secrets out of the repo mechanically

Finally, a small but high-leverage guard: a pre-push hook that refuses to push environment files, because a well-meaning local .env can clobber production configuration or leak a signing secret into history.

#!/usr/bin/env bash
set -euo pipefail
PATTERNS=('\.env$' '\.env\..*' 'supabase/\.env.*' '.*\.pem$' '.*service[-_]role.*')
files=$(git diff --cached --name-only --diff-filter=ACM)
fail=0
for f in $files; do
  for p in "${PATTERNS[@]}"; do
    if [[ "$f" =~ $p ]]; then
      echo "blocked: $f matches forbidden pattern /$p/" >&2
      fail=1
    fi
  done
done
if [[ $fail -ne 0 ]]; then
  echo 'Use the secret manager; never commit environment files.' >&2
  exit 1
fi

Note the hardening details: set -euo pipefail so an unset variable does not make the hook silently pass, anchored patterns so .env.example policy is deliberate rather than accidental, and a per-pattern message so the developer knows exactly which rule fired. A guard that fails open is not a guard.


Summary checklist

If you are building anything that gates physical or financial access:

  1. Terminal transitions are idempotent. Guard and write in one atomic statement; treat "already done" as success.
  2. Credentials derive from state. Never cache a key independently of the state that authorized it.
  3. Write the transition table down and make the compiler enforce exhaustiveness.
  4. Enforce the invariant in the database, because in-process checks lose races.
  5. Never let a truthy leftover object satisfy an auth step; scope, expire, and clear flow state.
  6. Make auth context required at the type level so identity cannot be silently dropped.
  7. Encapsulate privileged reads in definer functions with explicit claim checks and fixed search paths.
  8. Token-gate external delivery: signed, scoped, short-lived, revocable, audited.
  9. Sanitize third-party content at ingest, once.
  10. Name the timezone in every access decision.
  11. Ship error codes, not prose; localize exhaustively at the edge.
  12. Assert behavior, not source text, and isolate security tests so they can be trusted when they turn red.

None of these are exotic. The reason they matter is cumulative: each one closes a window, and in access control, a window is exactly what an attacker — or an unlucky race — needs.

Key Insights

1
State Management

Terminal transitions should converge, not collide

Check-out can be triggered by user, scheduler, or operator simultaneously. Guarding the state in the same SQL statement as the write (with coalesce for timestamps) makes the operation idempotent, eliminating duplicate side effects and spurious errors for the race loser.

2
Security

Leftover flow state is leftover authorization

A stale payment-state object allowed guests to reach confirmation without completing email+OTP. Truthiness checks are not authentication: verification state needs facility scope, attempt nonce, and TTL, and must be destroyed whenever the flow resets.

3
Type Safety

Exhaustive transition tables turn the compiler into a policy checker

Declaring allowed state edges with `satisfies Record<Status, readonly Status[]>` makes it impossible to add a status without declaring its guards, catching unreachable or unguarded states at build time rather than in production.

4
Authorization

Optional auth context is a latent vulnerability

A feature-flag check called without userId made the platform-admin bypass dead code. Making the policy subject a fully-populated required struct forces every call site to thread identity through, and default-deny ensures missing context fails closed.

5
Integration Security

Token-gate external key delivery with scope, expiry, and jti

Inbound PMS flows deliver credentials without app login. Signed tokens verified in constant time, limited to a single read scope, short-lived, revocable by jti, and audited on every redemption prevent enumeration of reservation-keyed URLs.

6
Testing

A flaky or false-green security test is worse than none

Asserting that source text contains a symbol passes on a comment. Behavioral assertions per environment plus throwaway per-test tenants for RLS checks make red results meaningful instead of something the team learns to re-run.

7
Reliability

Wall-clock correctness is an access-control property

Storing instants in UTC alongside the facility IANA timezone and converting explicitly at the edges prevents off-by-hours windows that either grant entry outside the authorized period or lock out legitimate guests.