Published on

The Direction of a Safe Default: Why keychainAccessible Makes "Convenience" the Default and Leaves "Security" to You

Authors
  • avatar
    Name
    Jack Qin
    Twitter

The most dangerous class of configuration error is the kind that makes nothing fail. It compiles, the tests are all green, the feature works as usual — the only thing that changed is one invisible security property. expo-secure-store's keychainAccessible is exactly this kind of knob: choose wrong, and the build doesn't error, runtime is normal, it's just that the user's password is quietly synced to every iOS device under the same Apple ID.

This post isn't about how to write one particular helper; it's about dissecting the two ledgers behind this knob: one is the direction of the default — why the Keychain API makes "cross-device convenience" the default and leaves "device-local security" for you to explicitly request; the other is the decisive variable for choosing a class — why what decides which keychainAccessible to use is not how sensitive the data is, but when you read it. Understand these two ledgers and you're facing not just one Expo API, but a whole class of "defaults biased toward convenience, security must be actively declared" storage/crypto interfaces.


The direction of the default: convenience first, security on request

iOS Keychain's accessibility attribute has an easily overlooked design lean: the default behavior favors convenience. When you pass no options object, the default keychain class is WHEN_UNLOCKED — and that class is eligible for iCloud Keychain sync. In other words, doing nothing, the secret you write is by default eligible to fan out to all the user's devices.

// ❌ No options passed
const email = await SecureStore.getItemAsync('auth.remember.email')
const password = await SecureStore.getItemAsync('auth.remember.password')

This code has no syntax or type error and runs perfectly fine. But it plants four sins, the first of which is fatal: no keychainAccessible → default WHEN_UNLOCKED → eligible for iCloud sync → the user's password fans out to every iOS device under the same Apple ID.

That's the cost of the default's direction. To nail storage as device-local, you must explicitly carry the THIS_DEVICE_ONLY suffix — security isn't gifted by default, it's something you actively request:

const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}

A hard rule follows: pass KEYCHAIN_OPTIONS to every setItemAsync / getItemAsync / deleteItemAsync. Miss it on even one call and that key silently falls back to the default WHEN_UNLOCKED — a hidden security regression. This repo has no helper that allows iCloud sync; there's no ADR for it, so don't introduce one.


The decisive variable for choosing a class: "when you read it," not "how sensitive"

Intuition tells you "the more sensitive the data, the stricter the class." But keychainAccessible's real decisive variable isn't sensitivity, it's when this value gets read — because different classes correspond to "what unlock state the device must be in for the data to be readable," and your read timing must fall inside that readable window, or you read back null.

Read happens at…Required classWhy
Cold start / pre-unlock background work (token, bootstrap state)AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLYThe boot path runs before the user enters their PIN; WHEN_UNLOCKED would block and return null
User-active screens only (login form, profile edit)WHEN_UNLOCKED_THIS_DEVICE_ONLYThe strictest class that still works. Closes the "phone locked but a background timer wakes the app" attack surface
Want cross-device convenience (deliberate iCloud sync)(default — omit THIS_DEVICE_ONLY)Almost never correct. Discuss before choosing

The logic of this table: first use "when you read it" to fix the most permissive class that works, then take the strictest one that still works. The token is read at cold-start bootstrap (the user hasn't unlocked yet), so it must be AFTER_FIRST_UNLOCK — choosing the stricter WHEN_UNLOCKED would make the boot path read null; remembered credentials are read when the login form mounts (the user is active, the device is unlocked), so it can and should use the stricter WHEN_UNLOCKED, closing the "background wake during lock screen" attack surface as a bonus. Three existing precedents neatly frame this design space:

FileRead timingKeychain class
lastSiteStorage.tsUI interaction(default — non-sensitive UI preference)
tokenStore.tsCold-start bootstrapAFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY
rememberedCredentials.tsLogin form mount (user active)WHEN_UNLOCKED_THIS_DEVICE_ONLY

Three companion contracts: envelope, self-heal, failure isolation

Around the keychain class, three more contracts together form a secure, self-consistent helper. All follow the same three-function shape (pure async, no class, no hook): load / save / clear.

Envelope shape (gated values only). When storing a gated value (the user toggled on/off, like "remember me"), load must return an envelope rather than the bare payload:

{
  enabled: boolean
  credentials: T | null
}

Because the consumer needs two independent signals to render the UI correctly: whether the gate was ever checked, and whether the payload is present. Collapsing to T | null loses the gate state — the UI then can't tell "the user chose off" from "the user chose on but the Keychain returned null." Non-gated values (a token pair, the last site id) can return the bare value.

Inconsistent-state self-heal (gated values only). If load reads enabled === "true" but any required payload field is missing, the helper must return { enabled: false, credentials: null } to the caller and fire-and-forget (no await) call clear() to wipe the residual keys. This prevents "ghost remember" — the gate shows ON but autofill is empty.

Failure isolation. Every SecureStore call is wrapped in try/catch; on failure it console.warns in dev, stays silent in prod, and never throws — even if the keychain is unavailable, the login/boot path must run to completion.

Put these together and you get the correct implementation:

// SecureStore prefix: auth.remember.*
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}

export async function loadRememberedCredentials(): Promise<{
  enabled: boolean
  credentials: RememberedCredentials | null
}> {
  try {
    const [enabled, email, password] = await Promise.all([
      SecureStore.getItemAsync('auth.remember.enabled', KEYCHAIN_OPTIONS),
      SecureStore.getItemAsync('auth.remember.email', KEYCHAIN_OPTIONS),
      SecureStore.getItemAsync('auth.remember.password', KEYCHAIN_OPTIONS),
    ])
    if (enabled !== 'true') return { enabled: false, credentials: null }
    if (email && password) {
      return { enabled: true, credentials: { email, password } }
    }
    void clearRememberedCredentials() // self-heal
    return { enabled: false, credentials: null }
  } catch (err) {
    if (__DEV__) console.warn('[rememberedCredentials] load failed:', err)
    return { enabled: false, credentials: null }
  }
}

Nail the "invisible property" into an executable assertion

Since choosing the wrong keychain class doesn't error, the only things that can catch it are tests and review. The most critical assertion is on the keychain class itself:

expect(SecureStore.setItemAsync).toHaveBeenCalledWith('auth.remember.email', 'alice@example.com', {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
})

Without this assertion, the helper can silently fall back to the default WHEN_UNLOCKED while every other test still passes — because the feature (storing and reading itself) isn't broken, only the security property changed. This is precisely why "the build doesn't error" problems must be gatekept by assertion: what you need to assert isn't behavior, but the property that doesn't affect behavior.

There's an associated mock trap worth recording: vitest.setup.ts's global mock explicitly exports only the keychain constants that are already used. When tokenStore.ts is the only helper using a THIS_DEVICE_ONLY class, the mock has only AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY; a new helper using WHEN_UNLOCKED_THIS_DEVICE_ONLY reads undefined under test, so the assertion above fails with keychainAccessible: undefined — even though the runtime code clearly passed the constant. The fix is to add that constant as one line in the mock (the value is arbitrary, never checked at runtime, but mirror the existing camelCase string convention). Prevention: if a helper chooses a class not yet in the mock, add it in the same commit.

The remaining assertions make a complete guard: Roundtrip (save → load), Clear (save → clear → load returns an empty envelope), Overwrite (save(a) → save(b) → load returns only b), inconsistent-state recovery (the gate helper's four branches: missing-A, missing-B, empty-string-A must call deleteItemAsync; enabled="false" must not), failure fallback (getItemAsync throws → load resolves empty, doesn't re-throw). Use the global mock and call __resetSecureStoreMock() in beforeEach; do not introduce a per-test vi.mock("expo-secure-store", ...) — it shadows the global mock and breaks the __reset invariant.

The file-header comment is mandatory. There's no central registry — each storage owner file declares its SecureStore prefix at the top, and the file-header comment is the registry.


The transferable layer

Set aside Expo and iOS Keychain's specific APIs, and there are two transferable lessons.

Many crypto/storage interfaces default toward convenience over security, so "security" is a posture you must re-declare at every call site, not a one-time global switch. Keychain's WHEN_UNLOCKED default, many SDKs' default log levels, an ORM's default soft-delete — what they share is "default loose unless you explicitly tighten." For this kind of interface, missing one spot is a hidden regression, and because it doesn't error, the only guards are the discipline of "pass explicit options at every call site" plus an assertion targeting that property.

When an error changes no behavior but only some invisible property, the test must directly assert that property. Behavior tests are an all-green placebo for this class of problem; the only effective guard is to pull out that invisible property (keychain class, TLS version, permission scope) and make an explicit assertion of it. When designing a security-sensitive wrapper, first ask: if someone quietly reverted this property to the insecure default, would my test go red?