UnlockOS Developers
← Back to blog
🔐

Hardening a Lock SDK: Secrets, Fail-Closed, Tokens

Aug 17, 2026Aug 23, 2026
9 min
165 commits
Depth 8/10
securityreliabilitytypescripterror-handlingtesting

Hardening a Lock SDK: Secrets, Fail-Closed Errors, and Tamper-Proof Auth

Introduction

A smart lock SDK is not a normal web dependency. It ships into browsers on a public CDN, it talks to hardware gateways that may be unreachable at 3 a.m., and it produces the audit trail that answers the question "who opened that door?". Every one of those properties changes what "good engineering" means.

This article distills a recent hardening cycle in our SDK and control plane into patterns that generalize: keeping secrets out of client bundles, redacting credentials from logs, refusing to trust client-held authorization state, single-flighting token refresh, choosing fail-closed error semantics, enforcing wire units at the type level, and ordering deployments so a migration never races an Edge Function.


1. Secrets never survive contact with a client bundle

The most expensive class of bug we fixed was an API key that reached a publicly cached CDN bundle. The key was only meant for a server-side helper, but a shared module got imported into a browser entry point and the bundler happily inlined process.env.*.

Two lessons:

  1. Rotation is the first step, not the fix. Once a key is in a CDN artifact it is public forever — caches, mirrors, and scrapers do not honor a force-push.
  2. Prevention belongs in CI, because code review will not catch an import chain five modules deep.

A build-time guard that scans emitted artifacts is cheap and catches the whole class:

#!/usr/bin/env bash
set -euo pipefail
# Fail the build if anything that looks like a provider key reaches dist/
PATTERNS='AIza[0-9A-Za-z_-]{35}|sk-[A-Za-z0-9]{20,}|SUPABASE_SERVICE_ROLE_KEY'
if grep -rEl "$PATTERNS" dist/ 2>/dev/null; then
  echo "::error::secret-like string found in build output" >&2
  exit 1
fi
echo "bundle scan clean"

On the source side, make the boundary explicit rather than implicit. A single module that is the only place allowed to read privileged env vars, plus a runtime assertion that it never executes in a browser:

// server-only.ts — imported exclusively from server entry points
if (typeof window !== 'undefined') {
  throw new Error('server-only module was bundled into a client entry point');
}
export function requireServerSecret(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`missing required secret: ${name}`);
  return value;
}

The throw turns a silent leak into a loud, immediate failure during E2E runs — which is exactly the trade you want.

Corollary: pin model/provider identifiers centrally

A related cleanup unified every call site onto one provider model constant and deleted retired identifiers. Scattered string literals are not just a maintenance problem: when a provider retires an ID, half your call sites start failing with vague 4xx errors and the other half keep working, producing an incident that looks non-deterministic. One exported constant plus a CI grep for retired IDs removes the ambiguity.

export const AI_MODEL = 'provider-3.7-flash' as const;
export type AiModel = typeof AI_MODEL;

2. Log the upstream error body — but redact the credentials

Debuggability and secrecy pull in opposite directions. When an upstream call fails, you want the response body in your logs; when it fails with an auth error, that body (and the request you echo alongside it) often contains the token.

The answer is not "log less", it is structured redaction at the logging boundary:

const SENSITIVE_KEYS = /^(authorization|x-api-key|cookie|set-cookie|apikey|token|refresh_token)$/i;
const SENSITIVE_VALUE = /(AIza[0-9A-Za-z_-]{20,}|Bearer\s+[A-Za-z0-9._-]{10,})/g;

export function redact(input: unknown, depth = 0): unknown {
  if (depth > 6) return '[depth-limit]';
  if (typeof input === 'string') return input.replace(SENSITIVE_VALUE, '[REDACTED]');
  if (Array.isArray(input)) return input.map((v) => redact(v, depth + 1));
  if (input && typeof input === 'object') {
    return Object.fromEntries(
      Object.entries(input as Record<string, unknown>).map(([k, v]) =>
        SENSITIVE_KEYS.test(k) ? [k, '[REDACTED]'] : [k, redact(v, depth + 1)],
      ),
    );
  }
  return input;
}

Wire it into a single logUpstreamFailure helper and forbid raw console.error(response) in lint rules. The same helper is where you decide what crosses the trust boundary back to the caller:

export function toClientError(err: unknown, requestId: string) {
  logUpstreamFailure({ requestId, detail: redact(err) }); // full detail, internal only
  return { code: 'upstream_unavailable', requestId }; // opaque, external
}

We applied exactly this after finding raw database errors escaping to API consumers: internal table and column names are reconnaissance material, and a requestId is all a support engineer actually needs to correlate.


3. Client-held role state is a hint, never a decision

An admin console persisted the operator's selected role in localStorage so the UI would restore after a reload. localStorage is attacker-writable by definition. The fix was a tamper guard: on restore, the stored value is intersected with the roles the server actually granted for the current session, and anything unexpected is discarded and recorded.

type RoleLevel = 'viewer' | 'staff' | 'manager' | 'owner';
const ORDER: RoleLevel[] = ['viewer', 'staff', 'manager', 'owner'];

export function restoreSelectedRole(
  stored: string | null,
  grantedFromSession: RoleLevel[],
): RoleLevel {
  const fallback = grantedFromSession[0] ?? 'viewer';
  if (!stored) return fallback;
  const candidate = ORDER.find((r) => r === stored);
  if (!candidate || !grantedFromSession.includes(candidate)) {
    auditLog('role_restore_rejected', { stored, granted: grantedFromSession });
    return fallback;
  }
  return candidate;
}

Two properties matter more than the code:

  • The server re-checks anyway. The guard improves UX and produces a signal; it is not the enforcement point. Every privileged endpoint still authorizes against session claims and row-level policies.
  • Rejection is an audit event. A tampered role value is one of the highest-signal indicators you can collect in an admin surface.

4. Single-flight token refresh, scoped to the tenant

Our lock provider issues short-lived tokens. Under burst load — a checkout sweep, a morning of arrivals — dozens of concurrent requests would each notice an expired token and each fire a refresh. The provider then rate-limits or invalidates earlier tokens, and the SDK sees a cascade of 401s that looks like an outage.

The fix is a single-flight (request coalescing) cache keyed by facility, so one tenant's refresh storm cannot stall another's:

type Token = { value: string; expiresAt: number };
const inflight = new Map<string, Promise<Token>>();
const cache = new Map<string, Token>();
const SKEW_MS = 60_000;

export async function getToken(facilityId: string): Promise<Token> {
  const cached = cache.get(facilityId);
  if (cached && cached.expiresAt - SKEW_MS > Date.now()) return cached;
  const existing = inflight.get(facilityId);
  if (existing) return existing;
  const p = fetchToken(facilityId)
    .then((token) => {
      cache.set(facilityId, token);
      return token;
    })
    .finally(() => {
      inflight.delete(facilityId); // always clear, success or failure
    });
  inflight.set(facilityId, p);
  return p;
}

Details that bite in production:

  • finally must clear the map, otherwise one failed refresh poisons the key forever.
  • Refresh before expiry using a skew window; clock drift between your runtime and the provider is real.
  • A related failure mode we hit was token cooling overnight: with no traffic, the cached token expired and the first request of the day paid a refresh plus a retry. A scheduled warm-up or an explicit "refresh on 401 exactly once" retry policy removes the cold-start 401.

5. Fail closed: never let "unreachable" masquerade as a business state

The most dangerous bug of the cycle was not a leak. When the lock provider stopped responding, an integration mapped the transport failure onto a domain state meaning "capacity full". Operators saw a plausible business message and made decisions on it. A transport failure had become a lie in the UI.

Model the two outcomes as different shapes so the compiler will not let you conflate them:

type ProviderResult<T> =
  | { kind: 'ok'; data: T }
  | { kind: 'domain'; reason: 'capacity_full' | 'not_permitted' }
  | { kind: 'unavailable'; retryable: true; cause: string };

async function queryCapacity(id: string): Promise<ProviderResult<Capacity>> {
  let res: Response;
  try {
    res = await fetchWithTimeout(`/capacity/${id}`, { timeoutMs: 5_000 });
  } catch (cause) {
    return { kind: 'unavailable', retryable: true, cause: String(cause) };
  }
  if (res.status >= 500 || res.status === 429) {
    return { kind: 'unavailable', retryable: true, cause: `http_${res.status}` };
  }
  if (res.status === 409) return { kind: 'domain', reason: 'capacity_full' };
  if (!res.ok) return { kind: 'unavailable', retryable: true, cause: `http_${res.status}` };
  return { kind: 'ok', data: await res.json() };
}

The rule we now apply across the SDK: an absent answer is never rendered as a definite answer. Timeouts, 5xx, and parse failures map to unavailable, which the UI renders as "we could not reach the lock service" with a retry — not as a confident business fact.


6. Put units and widths in the type system

Two firmware-adjacent defects shared a root cause: an integer whose meaning lived only in a comment.

  • A dispatcher sent an epoch in milliseconds on a wire contract that specified seconds, so signed commands failed verification.
  • Command-signing epochs were narrowed to 32-bit, a latent 2038 overflow in a device that will still be on a door in 2038.

Branded types make the unit part of the contract, and a single assertion function is the only way to create one:

declare const brand: unique symbol;
export type EpochSeconds = number & { readonly [brand]: 'EpochSeconds' };

export function toEpochSeconds(ms: number): EpochSeconds {
  if (!Number.isFinite(ms)) throw new TypeError('non-finite timestamp');
  const seconds = Math.floor(ms / 1000);
  if (seconds < 0 || seconds > 4_102_444_800) throw new RangeError('epoch out of range');
  return seconds as EpochSeconds;
}

export function signCommand(payload: Payload, issuedAt: EpochSeconds): string {
  // `number` no longer type-checks here — the unit mistake is a compile error
  return hmac(`${payload.deviceId}.${payload.action}.${issuedAt}`);
}

On the firmware side the equivalent move is widening to int64_t and refusing to build against a toolchain with a 32-bit time_t. A build-time rejection of placeholder .env values belongs in the same category: make the wrong configuration unbuildable rather than detectable in the field.


7. Deployment ordering is part of your reliability story

Two CI changes prevented a whole family of half-deployed states:

  1. Gate serverless function deploys on migration completion. If functions ship first, new code queries columns that do not exist yet, and every request in that window is a 500.
  2. Make migration jobs non-cancellable. A cancelled workflow that kills migrate mid-apply can leave the schema in an intermediate state that neither the old nor the new code understands.
jobs:
  migrate:
    runs-on: ubuntu-latest
    concurrency:
      group: db-migrate-${{ github.ref }}
      cancel-in-progress: false   # never interrupt an in-flight apply
    steps:
      - run: ./scripts/migrate.sh --transactional
  deploy-functions:
    needs: migrate                # ordering is explicit, not hopeful
    runs-on: ubuntu-latest
    steps:
      - run: ./scripts/deploy-functions.sh

Pair this with expand/contract migrations (add nullable column → backfill → dual-write → switch reads → drop) so that any ordering, even a failed one, leaves both versions of the code able to run.


8. Audit trails need a canonical source of truth

Unlock events are the compliance artifact of a lock system, so attribution errors are severe. We found that unlock events on a shared lock were being attributed to whichever reservation happened to overlap in time — meaning one guest's entry could appear in another guest's history.

The fix was to stop inferring and start joining against the canonical mapping (the room's declared lock IDs), and to scope every query by facility:

select e.id, e.occurred_at, e.credential_id, r.id as reservation_id
from unlock_events e
join rooms rm
  on e.lock_id = any(rm.lock_ids)
 and rm.facility_id = e.facility_id
left join reservations r
  on r.room_id = rm.id
 and r.facility_id = e.facility_id
 and e.occurred_at between r.access_start_at and r.access_end_at
 and r.credential_id = e.credential_id   -- identity, not just time overlap
where e.facility_id = $1
order by e.occurred_at desc;

The principles: attribute on identity, not on temporal coincidence; keep the tenant scope in every join condition rather than only in the outer where; and surface failed unlock attempts in the same view — a denied attempt is often more interesting than a successful one.


9. A backup you have never restored is a hypothesis

We added two workflows, not one: a scheduled logical backup and a scheduled restore test that provisions a throwaway database, restores the latest artifact, and asserts invariants (row counts, migration head, a few critical constraints). The restore job also derives its connection URL from the existing password secret rather than duplicating a second credential — fewer secrets, fewer rotation gaps.

restore-test:
  schedule: { cron: '0 3 * * *' }
  steps:
    - run: ./scripts/restore.sh --into "$EPHEMERAL_DB_URL" --artifact latest
    - run: psql "$EPHEMERAL_DB_URL" -v ON_ERROR_STOP=1 -f ./scripts/restore-assertions.sql

Summary

The patterns that earn trust in a security-critical SDK are mostly about removing ambiguity:

  • Secrets cannot leak from a boundary that throws when crossed, and CI scans the artifact, not the intent.
  • Logs stay useful and safe when redaction lives at one enforced choke point; external errors carry a correlation ID, not a stack trace.
  • Client-persisted authorization state is validated against server grants, and rejections are audited.
  • Token refresh is single-flighted per tenant so load spikes cannot manufacture auth outages.
  • Transport failure and business state are different types, so "unknown" can never render as "full".
  • Units and integer widths live in the type system, not in comments.
  • Deploy ordering is declared in the pipeline, and migrations are never cancelled mid-apply.
  • Audit attribution joins a canonical mapping on identity, scoped by tenant.
  • Backups are verified by an automated restore, on a schedule.

None of these are exotic. They are the difference between a system that appears to work and one whose failure modes you can describe before they happen.

Key Insights

1
Security

Treat bundle output as a secret-scanning target

An API key reached a public CDN bundle through a deep import chain. Rotation is only step one; the durable fix is a server-only module that throws in browsers plus a CI scan of emitted artifacts for key-shaped strings.

2
Security

Redact at one enforced logging choke point

Upstream error bodies are essential for debugging and dangerous to log verbatim. A recursive redactor keyed on header/field names plus token patterns keeps internal logs complete while external responses carry only an opaque code and request ID.

3
Authorization

Client-persisted role state is a hint, not a decision

Roles restored from localStorage are intersected with server-granted roles; mismatches fall back to the least privilege and emit an audit event. The server still authorizes independently on every privileged call.

4
Reliability

Single-flight token refresh scoped per tenant

Concurrent expiry detection caused refresh storms and cascading 401s. Coalescing refreshes in a per-facility in-flight map, clearing the entry in finally, and refreshing ahead of a clock-skew window removes the self-inflicted outage.

5
Error Handling

Never render 'unreachable' as a business state

A provider timeout was being mapped to a domain reason meaning 'capacity full', making the UI confidently wrong. A discriminated union separating ok / domain / unavailable makes the conflation a compile error.

6
Type Safety

Encode units and integer widths in types

A milliseconds-vs-seconds wire mismatch broke command signature verification, and 32-bit epochs hid a 2038 overflow. Branded types with a single validating constructor, plus int64 on the firmware side, move both bugs to build time.

7
Operations

Ordering and restore tests are part of correctness

Function deploys are gated on migration completion and migration jobs are non-cancellable, preventing half-deployed schemas. Backups are paired with a scheduled restore-and-assert job so recovery is verified, not assumed.

8
Audit

Attribute unlock events on identity, not time overlap

Shared locks caused unlock events to be assigned to whichever reservation overlapped in time. Joining the canonical room-to-lock mapping with credential identity, scoped by facility in every join, restores a trustworthy audit trail.