Makes sign-in safe and complete on iOS and Android: tokens live only in the Keychain or Keystore behind one port, one session manager restores, refreshes and ends the session without races, route groups open and close with it, OAuth runs the code flow with PKCE, Sign in with Apple uses a nonce, and sign-out leaves nothing of the last person behind. A zero-dependency checker proves the structure; Jest tests prove the behaviour.
Rules that must hold
- Tokens live only in expo-secure-store, behind
SecureStoragePort. One adapter imports the SDK and passes one frozen options object (keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY) to every async call; nevergetItem/setItem, neverALWAYS, norequireAuthenticationon session tokens. Why: a read with other options finds nothing;WHEN_UNLOCKEDbreaks background refresh; biometrics prompt on every Android read and a new fingerprint signs people out. (vendor-import,secure-store-call) - A credential appears nowhere else. Not in SQLite, kv-store, AsyncStorage, MMKV or files, not in a
Zustand store (not even a bare
tokenfield), not in a log, crash report or analytics event, not in a TanStack mutation (its cache keeps variables and data), not in a URL, never inEXPO_PUBLIC_*. Why: none of those is encrypted with a hardware key, and logs, backups and URLs travel. (token-storage,token-in-state,token-in-log,token-in-url; these four run in every app) - Every secret key is declared once in
SECURE_KEYS; wipes loop overALL_SECURE_KEYS. Why: a key typed by hand somewhere survives sign-out and the fresh-install wipe. (secure-keys) - A fresh install wipes what the Keychain kept. An install marker in the app sandbox
(kv-store) is checked at restore: missing means wipe every key, then mark; unreadable means never
wipe. Why: the iOS Keychain outlives an uninstall, so a reinstall would sign the previous person
back in. (
fresh-install-wipe) - Restore never guesses, and never waits forever. A read that fails in the background publishes
unavailableand is retried in the foreground; one that fails in the foreground (read twice) signs out with reasonstorage-unavailableand keeps the values; an unreadable session signs out but is kept. The restore is app-startup-and-bootstrap's blocking startup-gate taskrestoreSessionTaskunder itsdeadlineMs(that skill owns the one splash hold and release; auth calls noSplashScreenfunction); a miss opens only a "checking your session" route, never anullframe. Why: a locked phone or a parser bug must not destroy a valid session, and a broken Keystore must not keep the app checking so long that the person can never reach sign-in. (session-manager, session-context and session-guards tests) - Persist, then publish; a sign-out always wins. Sign-in writes the refresh token first, then the
rest, and only then publishes
signed-in; every write and wipe runs through one queue, and a generation number drops work started before a sign-out. Why: the reverse order shows the app and loses the session at the next launch; a late refresh would sign a signed-out person back in. (session-manager tests) - One token issuer, one refresh at a time, owned by the session manager. Every sign-in method
ends in tokens from the issuer whose
TokenRefresherthe session holds: with an app server (Apple needs one) each method is exchanged there and the refresher iscreateServerTokenRefresher; the OAuth refresher only when OAuth is the sole method and there is no server. Callers share the in-flight refresh; only the session manager calls the refresher. Why: a refresh token sent to the wrong issuer answersinvalid_grantone token lifetime later, and two refreshes with a rotating refresh token look like a replay that revokes every session. (single-flight-refresh, tests) - Only the server ends a session.
invalid_grantor another OAuth error ends it; no connection, a timeout or 5xx keep it, and an unexpired token still goes out offline; a 401 renews once (sendWithSession), a second 401 signs out. Why: a train tunnel must never sign anyone out. (token-set and send-with-session tests) - Protected groups are guards, not navigation. Each signed-in group has its own
_layoutand sits inside<Stack.Protected guard={isSignedIn}>; signed-out routes sit insideguard={isSignedOut}; flows switch by changing the session, never withrouter.replace; noredirectTobefore SDK 58. Why: without the group layout the guard protects none of its screens (verified), and a deep link opens them signed out. (protected-groups, session-guards test) - Sign-out clears everything, in two phases. First, on
signOut('user'), theserverSignOutSteps(push unregistration, server logout) run with the still-valid token under one 5 s deadline; then wipe every key, publishsigned-out(protected screens leave), run everysignOutStepsentry (query cache, account stores, rows, reminders) even when one fails, and revoke the refresh token best effort. Both lists are named incontracts/auth.json. Why: a cache nobody cleared shows the next person this person's data, and a push registration nobody removed sends them this person's notifications; after the wipe no server call can be authorized. (sign-out-steps) - OAuth is the authorization code flow with PKCE (S256) in an ephemeral system browser. No
implicit flow, no WebView, no client secret, the
code_verifierpassed toexchangeCodeAsync(expo-auth-session does not add it). Why: anything else leaks tokens or codes, and a shared browser cookie signs the last person straight back in. (oauth-pkce,oauth-ephemeral) - Sign in with Apple is iOS-only, configured, and nonced.
usesAppleSignIn(or the plugin), a fresh nonce hashed with SHA-256,ERR_REQUEST_CANCELEDsilent, the credential verified by the app's server, the name and email saved on the first sign-in, a revoked credential signing out, Apple's own button. Offer it (or an equal private login) whenever a third-party login sets up the account (App Store guideline 4.8). (apple-sign-in) - Android backups leave the secure store out.
['expo-secure-store', { faceIDPermission: false }]inapp.config.ts, or the app's own rules named in the contract. Why: prebuild does not apply this plugin by itself, and a restored copy cannot be decrypted on the new phone. (secure-store-plugin) - An app that creates accounts deletes them inside the app. A route in the signed-in group, the
server deletes (and revokes Apple tokens), then
signOut('account-deleted'). Why: App Store guideline 5.1.1(v) and Google Play (which also wants a web link) require it. (account-deletion)
Workflow
- Read
references/session-lifecycle.md(the states, races and sign-out order) andreferences/secure-storage.md(platform behaviour). For the provider readreferences/oauth-and-apple.md; for routesreferences/protected-routes.md. - Install the packages and edit
app.config.ts(references/adopting-in-an-app.mdsections 1-2): the expo-secure-store plugin withfaceIDPermission: false, Apple's plugin, a scheme per variant. - Copy every file under
templates/to the path its first line names, tests included; the contract and schema land incontracts/. - Add the four ports to the composition root and its test, and record the SDKs in
contracts/architecture.json(examples/create-device-services.ts,examples/services-context.tsx, adopting-in-an-app.md sections 4-5). - Read the provider settings in
src/config/oauth-config.ts(examples/oauth-config.ts); register<scheme>://oauth-redirectfor each variant with the provider (Google refuses custom schemes on Android:references/oauth-and-apple.mdsection 3). Stop and ask the developer for the issuer, client id and Apple team setup: they are outward-facing accounts. - Pick the one token issuer (rule 7): with a server, every method exchanges there and the session gets
examples/server-refresher.ts. Create the session once (examples/create-app-session.ts) inAppRoot, renderSessionProviderinRootStack(examples/root-stack.tsx), and guard the Stack (examples/app-stack.tsx); give each protected group its own_layout.tsx. - Build sign-in as a feature:
examples/use-sign-in-model.ts,examples/sign-in-screen.tsx,examples/sign-in-route.tsx, the Android redirect route (examples/oauth-redirect-route.tsx,examples/use-oauth-redirect.ts), and on iOSexamples/use-apple-sign-in.tsoverexamples/auth-mutations.ts. - Send requests to the app's server through
examples/authorized-api-client.ts(sessionHeadersandsendWithSession), pass every per-account cache as a sign-out step and every server call that needs the session (push unregistration) as a server sign-out step; list both incontracts/auth.json. - When the app creates accounts, build deletion (
examples/use-delete-account-model.ts) and name the route in the contract. Test it against a staging server only, never production. - Write a test for every example you adopted (
createSessionTestKitkeeps them short), and a router test for the app's own AppStack (fromsession-guards.test.tsx: a signed-out deep link lands on sign-in; the checker cannot see a guard bound to the wrong boolean), runnpx jest --ci(the templates' tests cover rules 5-8, 10 and 9's behaviour), then the checker:node ${CLAUDE_SKILL_DIR}/scripts/check-auth.mjs .; fix and rerun untilRESULT: PASS. Look up a rule inreferences/checker-rules.md. - Hand the device checklist (
references/oauth-and-apple.mdsection 9) to a human for one named iOS device and one named Android device before release.
Definition of done
- Tokens only behind
SecureStoragePort; one adapter, one frozen options object. - No credential in plain storage, stores, logs, URLs or
EXPO_PUBLIC_*. - Fresh-install wipe;
unavailablein the background,storage-unavailableafter two failed foreground reads (values kept); the restore is the startup gate's blocking task, and a miss shows the checking-session route (noSplashScreencall in auth code, nonullframe). - Sign-in persists before it publishes; a sign-out during sign-in or refresh wins (tests pass).
- One token issuer per session (every sign-in method ends at the refresher's issuer); one shared refresh in the session manager; a 401 renews once, then signs out.
- Every protected group has its own
_layoutand a guard; norouter.replaceafter sign-in/out. - Every per-account cache is a sign-out step, every server call that needs the session a server
sign-out step, both listed in
contracts/auth.json. - OAuth: code + PKCE S256, ephemeral, verifier on exchange, no client secret; redirect per variant.
- Sign in with Apple configured with a nonce and a silent cancel (when offered).
- expo-secure-store plugin listed; account deletion route when accounts are created.
-
npx jest --cipasses, includingsession-guards.test.tsxand a test per adopted example. -
node ${CLAUDE_SKILL_DIR}/scripts/check-auth.mjs .printsRESULT: PASS(no SKIP lines exist;NOT APPLICABLEonly while no app file imports an auth package, no contract exists and no credential leaks; an app with secrets but no sign-in writes{ "signIn": false }and passes the storage rules,references/secure-storage.mdsection 7).
Anti-patterns
- Publishing the session before the write (Expo's sample
useStorageStatesets state, then writes without awaiting). The UI shows the app; a failed write signs the person out next launch. router.replace('/')aftersignIn(). The guard already navigates; a second navigation races it.- A guard around a group without its own
_layout. It protects nothing and Expo Router says nothing. - Refreshing in an Axios-style interceptor per request. Ten 401s, ten refreshes, a revoked token
family. Share one refresh in the session manager; a server refresh endpoint is a
TokenRefresher. - Wiping, then calling the server at sign-out. The push unregister goes out signed out and fails silently; make it a server sign-out step.
- Waiting for a Keystore that stays broken.
unavailableforever is a checking screen forever. - Hiding the splash from
AppStackwhenisRestoringends, or returningnullwhile restoring. The hold and release are app-startup-and-bootstrap's; once its gate opened,nullis a blank frame. Make the restore a gate task and render the guarded checking-session route. - Signing out on any refresh error. Offline is not expired; only
invalid_grantends a session. - Wiping the session on a read error. A locked keychain is not an empty one.
- Clearing only the tokens at sign-out. The query cache and account stores still show the last person's data; make each one a step.
- Tokens in a Zustand
persiststore or kv-store "for speed". Plain files, in backups. - Sign-in exchanges through
useMutation. The MutationCache keeps the credential and the tokens. - A client secret in
app.config.tsorEXPO_PUBLIC_*. Public the moment the app ships. - Trusting the id token on the device. Read its claims for display; the server verifies.
- Calling Apple's
signOutAsyncto sign out. It shows Apple's sheet; clear the app's session. - A hand-drawn Apple button. It must follow Apple's branding rules; use Apple's own through the port.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
references/session-lifecycle.md |
States, restore, persist-then-publish, single-flight refresh, 401s, sign-out order, races, startup order, test recipes | Before building or changing the session |
references/secure-storage.md |
Keychain and Keystore behaviour, options, keys, uninstall, backups, biometrics, verified facts | Storing any secret; a secret is lost or survives |
references/oauth-and-apple.md |
Code + PKCE with expo-auth-session 57, redirects, errors, id tokens, Sign in with Apple, guidelines 4.8 and 5.1.1(v), Google Play deletion, device checklist | Adding a provider or Apple; before release |
references/protected-routes.md |
Stack.Protected behaviour (verified), the group-layout pitfall, the checking-session route while restoring, SDK 57 limits | Guarding routes |
references/adopting-in-an-app.md |
Every edit to adopt the templates, as verified on the reference app | Workflow steps 2-9 |
references/checker-rules.md |
Each check-auth rule, the contract keys, config keys | Reading a check-auth result |
templates/src/core/session/ |
token-set.ts (tokens, expiry, which failures end a session, meta schema), id-token-claims.ts (unverified display claims), tests |
Step 3 |
templates/src/services/secure-storage/ |
SecureStoragePort, the expo-secure-store adapter, SECURE_KEYS, the fake, tests |
Step 3 |
templates/src/services/install-marker/ |
InstallMarkerPort, the kv-store adapter, the fake, tests |
Step 3 |
templates/src/services/oauth/ |
OauthPort and OauthConfig, the expo-auth-session adapter (PKCE, ephemeral, timeouts), the fake, tests |
Step 3 |
templates/src/services/apple-sign-in/ |
AppleSignInPort (with Apple's button), the expo-apple-authentication adapter (nonce), the fake, tests |
Step 3 |
templates/src/services/session/ |
The session manager and its machine (state, queue, generation), types, vault, single-flight, the server TokenRefresher (createServerTokenRefresher), server and local sign-out steps and factories, sendWithSession/sessionHeaders, SessionProvider and hooks, guards with the router test |
Step 3 |
templates/test/session/ |
createSessionTestKit: a real session over fakes, for screen and route tests |
Testing code that uses a session |
templates/contracts/ |
auth.json (the app's sign-in facts) and its schema |
Step 3, then fill in |
examples/ |
Verified adoption shapes: composition root, Services, config, session creation with the server refresher (server-refresher.ts), root stack, guarded Stack, sign-in feature (id token exchanged at the server), redirect route, Apple flow, auth exchanges, authorized ApiClient, account deletion |
Steps 4-9 |
scripts/check-auth.mjs |
The checker (17 rules; storage rules only with "signIn": false) |
Step 10 and the definition of done |
scripts/lib/auth-rules.mjs |
The checker's rules | Changing the checker |
scripts/lib/auth-source.mjs |
Source loading and text helpers for the rules | Changing the checker |
scripts/check-lib.mjs |
Shared checker library (synced; never edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared source scanner (synced; never edit here) | Never by hand |
scripts/selftest.mjs |
Proves the checker on planted bugs | After changing the checker |
tests/bases/app/ |
The fixture base app, with planted installed-skill findings that must stay silent | The self-test reads it |
tests/fixtures/check-auth/ |
One overlay case per rule (good, bad-, pass-, error-*) on the base, templates and examples | The self-test reads them |
assets/shared.json |
Which shared libraries are synced into this skill | Maintenance |
Related skills
networking-and-server-state- the ApiClient, mutations and the query cache sign-out clears.state-management-zustand- an account store holding only non-secret facts.architecture-and-boundaries- the composition root and the SDK lists.navigation-and-routing- the navigator tree the guards sit in.app-startup-and-bootstrap- the startup order and the splash screen.app-variants-and-environment- schemes andEXPO_PUBLIC_*per variant.expo-prebuild-and-config-plugins- the native snapshot after adding the plugins.push-notifications- the push registration a server sign-out step removes.error-handling-typed-results- Result failures and recording.async-safety- deadlines and effects that own their work.privacy-and-store-declarations- data-safety answers and export compliance.unit-and-component-tests- the router test helper and test conventions.