React Native
⚠️ Watch for Universal Link / App Links conflicts with the OAuth callback domain
If the RP app claims a universal/app link on the same host as the IdP, an unrelated deep link arriving mid-sign-in is forced into the OAuth parser and produces a spurious missingCode error (the ainote incident on 2026-05-15). Receive the callback via a custom scheme (<app>://oauth/1pass/callback) wherever possible.
Diagnostics: https://api.1pass.dev/diagnose
Custom scheme pitfall
iOS rejects schemes that contain an underscore (_). my_app:// ❌ → com.example.myapp:// or myapp:// ✅
Use react-native-keychain for token storage on both platforms and react-native-app-auth for ASWebAuthenticationSession / Custom Tabs unified OAuth + PKCE.
Expo Web / Hybrid targets
On browser pages in an Expo Web or react-native-web environment, you can use the npm package @logi-auth/browser directly (npm install @logi-auth/browser). A separate SDK exists for native iOS only (the LogiAuth Swift Package; see the iOS guide), and React Native requires a native bridge — there is no automatic integration package yet (TODO).
Dependencies
{
"dependencies": {
"react-native-keychain": "^10.0.0",
"react-native-app-auth": "^8.0.3"
}
}Minimum version requirements
- React Native 0.73+
- iOS 13+, Android API 23+
- iOS requires
NSFaceIDUsageDescriptioninInfo.plist(when using biometric)
⚠️ Watch for SoLoader / Hermes version conflicts
react-native-keychain 10.x is sensitive to the SoLoader version. If the app crashes at startup, explicitly pin the SoLoader version in android/app/build.gradle.
OAuth + PKCE flow
react-native-app-auth handles PKCE automatically:
import { authorize, refresh, AuthConfiguration } from 'react-native-app-auth';
const config: AuthConfiguration = {
issuer: 'https://api.1pass.dev',
clientId: 'logi_...',
redirectUrl: 'com.example.myapp://callback',
scopes: ['openid', 'profile:basic', 'email'],
// PKCE is enabled by default. usePKCE: true (default)
serviceConfiguration: {
authorizationEndpoint: 'https://api.1pass.dev/oauth/authorize',
tokenEndpoint: 'https://api.1pass.dev/oauth/token',
revocationEndpoint: 'https://api.1pass.dev/oauth/revoke',
},
};
export async function signIn() {
const result = await authorize(config);
// result.accessToken, result.refreshToken, result.accessTokenExpirationDate
await TokenStorage.save(result.accessToken, result.refreshToken);
return result;
}Storing the Refresh Token (react-native-keychain)
If you do not specify the options, iOS may sync to iCloud and Android may use plain storage instead of the KeyStore. Always specify every option.
import * as Keychain from 'react-native-keychain';
const TOKEN_SERVICE = 'logi.tokens';
const DEVICE_SECRET_SERVICE = 'logi.device_secret';
export const TokenStorage = {
async save(accessToken: string, refreshToken: string) {
// Size check: react-native-keychain can corrupt data above 65KB
const payload = JSON.stringify({ accessToken, refreshToken });
if (payload.length > 60_000) {
throw new Error('Token payload exceeds safe limit (60KB)');
}
await Keychain.setGenericPassword('logi', payload, {
service: TOKEN_SERVICE,
// ⚠️ blocks iCloud sync
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
// Android: AES + Keystore backed
storage: Keychain.STORAGE_TYPE.AES_GCM, // hardware-backed when available
});
},
async load(): Promise<{ accessToken: string; refreshToken: string } | null> {
const creds = await Keychain.getGenericPassword({ service: TOKEN_SERVICE });
if (!creds) return null;
return JSON.parse(creds.password);
},
async clear() {
await Keychain.resetGenericPassword({ service: TOKEN_SERVICE });
},
};Accessibility options summary
| Option | Locked-state access | iCloud sync | Scenario |
|---|---|---|---|
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY | ✅ | ❌ | Recommended default (background refresh OK) |
WHEN_UNLOCKED_THIS_DEVICE_ONLY | ❌ | ❌ | Highest sensitivity |
AFTER_FIRST_UNLOCK | ✅ | ⚠️ on | When iCloud sync is intended |
WHEN_UNLOCKED | ❌ | ⚠️ on | ❌ not recommended |
Android backup exclusion
React Native Android projects also need backup rules:
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="RN_KEYCHAIN.xml" />
</full-backup-content><!-- AndroidManifest.xml -->
<application android:fullBackupContent="@xml/backup_rules" ...>Adding biometric for sensitive operations
Adding accessControl: BIOMETRY_CURRENT_SET to the options makes the token itself biometric-protected. Since this is incompatible with background refresh, apply it only to a high-assurance token stored under a separate service:
const HIGH_ASSURANCE_SERVICE = 'logi.high_assurance';
await Keychain.setGenericPassword('logi', sensitiveToken, {
service: HIGH_ASSURANCE_SERVICE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
// BIOMETRY_CURRENT_SET: the key is invalidated when the enrolled biometric changes
authenticationPrompt: {
title: 'Authenticate for a sensitive action',
subtitle: 'Face ID / fingerprint authentication is required',
cancel: 'Cancel',
},
});
// The biometric prompt is shown automatically on retrieval
const creds = await Keychain.getGenericPassword({ service: HIGH_ASSURANCE_SERVICE });⚠️ Background refresh and biometric are incompatible
Do not apply accessControl to a normal refresh token. It blocks silent refresh.
Device Bootstrap (device_secret)
logi's device bootstrap is dual-mode:
- First call (bootstrap):
POST /api/v1/deviceswith{device_uuid, platform}→ the response revealsdevice_secretonce + issues a PAK - Subsequent calls (refresh):
{device_uuid, platform, device_secret}to the same endpoint → the server verifies the digest and issues a new PAK. 401 if missing/mismatched.
If you lose the device_secret, an anonymous user has to re-authenticate via OAuth login.
// Handling the first-call response
const resp = await fetch(`${BASE}/api/v1/devices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_uuid: uuid, platform: 'ios' }),
});
const data = await resp.json();
if (data.device_secret) {
await saveDeviceSecret(data.device_secret);
}
// When refreshing the PAK afterward
const secret = await loadDeviceSecret(); // if null, prompt an OAuth re-login
await fetch(`${BASE}/api/v1/devices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_uuid: uuid, platform: 'ios', device_secret: secret }),
});Split into a separate service:
export async function saveDeviceSecret(secret: string) {
await Keychain.setGenericPassword('logi', secret, {
service: DEVICE_SECRET_SERVICE,
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
storage: Keychain.STORAGE_TYPE.AES_GCM,
});
}
export async function loadDeviceSecret(): Promise<string | null> {
const creds = await Keychain.getGenericPassword({ service: DEVICE_SECRET_SERVICE });
return creds ? creds.password : null;
}❌ Never store it in AsyncStorage — that is unencrypted SQLite/file storage.
Review checklist
- [ ]
accessibleoption specified with a..._THIS_DEVICE_ONLYvariant - [ ]
storage: AES_GCMto use Android Keystore backing - [ ] payload size verified under 60KB
- [ ]
RN_KEYCHAIN.xmlexcluded inbackup_rules.xml - [ ]
NSFaceIDUsageDescriptionadded to iOSInfo.plist(when using biometric) - [ ] SoLoader / Hermes version consistency
- [ ]
device_secret, refresh token, and high-assurance token in separate services - [ ] biometric only on the high-assurance service (incompatible with background refresh)