@getbitid/react-native
v0.9.0
Published
React Native adapter for the BitID SDK — implements @getbitid/core's ports (expo-camera, SecureStore/MMKV, a pinned fetch), exposes the holder flows, and ships the <SignInWithBitID> button.
Maintainers
Readme
@getbitid/react-native
Sign in and sign up with BitID, inside your app. This package ships a native bottom-sheet auth flow — identifier → OTP → (sign-up: name, phone, DOB, email) → scope consent → OAuth authorization code — that you mount as a normal React component. No WebView, no browser hand-off, no redirect out of your app.
It also carries the browser-based SignInWithBitID button and the @getbitid/core React
Native composition root; see Other surfaces.
Quick start
npm install @getbitid/core @getbitid/react-native
npx expo install expo-cryptoimport React, { useMemo, useState } from 'react';
import { View } from 'react-native';
import * as Crypto from 'expo-crypto';
import {
BitIDAuthFlow,
BitIDButton,
BitIDThemeProvider,
partnerProxyTransport,
useBitIDAuth,
type BitIDFlow,
type BitIDPkce,
} from '@getbitid/react-native';
const PARTNER_BACKEND = 'https://api.acme.example';
const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
async function createPkce(): Promise<BitIDPkce> {
const codeVerifier = hex(Crypto.getRandomBytes(32));
const digest = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 },
);
return {
codeVerifier,
codeChallenge: digest.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
state: hex(Crypto.getRandomBytes(16)),
};
}
export function Connect() {
const [flow, setFlow] = useState<BitIDFlow | null>(null);
const transport = useMemo(
() => partnerProxyTransport({ baseUrl: PARTNER_BACKEND }),
[],
);
const ports = useMemo(
() => ({
tokenizer: {
async tokenize(values) {
const r = await fetch(`${PARTNER_BACKEND}/bitid/tokenize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
return r.json();
},
},
clientIp: {
async getClientIp() {
const r = await fetch(`${PARTNER_BACKEND}/bitid/client-ip`, { method: 'POST' });
return (await r.json()).ip;
},
},
}),
[],
);
const { state, controller } = useBitIDAuth(
{
clientId: 'bitid-partner-acme',
redirectUri: 'acme://bitid-callback',
scopes: ['bitid.identity.read'],
exchange: 'partner-backend',
},
{
transport,
createPkce,
ports,
onAuthorizationCode: async (payload) => {
await fetch(`${PARTNER_BACKEND}/bitid/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
},
},
);
const open = (next: BitIDFlow) => {
setFlow(next);
controller.open(next);
};
return (
<BitIDThemeProvider>
<View>
<BitIDButton label='Sign in with BitID' onPress={() => open('signin')} />
<BitIDButton label='Sign up with BitID' variant='secondary' onPress={() => open('signup')} />
</View>
<BitIDAuthFlow
visible={flow !== null}
flow={flow ?? 'signin'}
state={state}
controller={controller}
onClose={() => setFlow(null)}
/>
</BitIDThemeProvider>
);
}A complete, runnable version of this — split into config.ts, pkce.ts, and
partnerBackend.ts — is in example/.
Install and peer dependencies
npm install @getbitid/core @getbitid/react-native@getbitid/core is the package's only runtime dependency. Everything else is a peer the
host app provides:
| Peer | Required for | Optional |
| --- | --- | --- |
| react (>=18) | everything | no |
| react-native (>=0.73) | everything | no |
| expo-crypto | your createPkce() (the SDK never generates PKCE for you) | yes¹ |
| expo-web-browser, expo-linking | the browser-based /signin button only | yes |
| expo-secure-store, react-native-mmkv | tieredStorage() backends | yes |
| expo-camera | liveness via @getbitid/core | yes |
¹ expo-crypto is optional to the package (it is your app that imports it), but you need
some SHA-256 + CSPRNG to build a BitIDPkce. The auth sheet itself imports no native
module beyond react-native, so it runs in Expo Go.
There is no Sardine peer to install. Device risk signals used to require
@sardine-ai/react-native-sardine-static-sdk; that peer is gone. The Sardine binary now ships
inside this package (ios/vendor/MDI.xcframework, android/vendor/m2) behind the package's own
native module, so the two lines above are the whole dependency story —
details below.
The bottom sheet is built from react-native primitives only — Modal, Animated,
Pressable, KeyboardAvoidingView. Nothing in the sheet needs linking; the risk-signals
native module does (autolinked, but it needs a native build — see below).
The transport — one path only
Every BitID call the sheet makes goes through a BitIDTransport. There is exactly one
implementation a partner app may use, and it is not a preference: your app never calls the
BitID backend directly. Every call goes to your backend, which attaches the service
credential server-side and forwards it.
import { partnerProxyTransport } from '@getbitid/react-native';
const transport = partnerProxyTransport({
baseUrl: 'https://api.acme.example',
authHeaders: async () => ({ authorization: `Bearer ${await appSession()}` }),
});Your backend fronts every call. The transport POSTs the GraphQL operation to
${baseUrl}/bitid/graphql with Content-Type: application/json plus whatever
authHeaders() returns; when authorization() is supplied its value is sent as
x-bitid-user-authorization.
Why: your backend is the only thing that talks to BitID, so it holds the service-account key, can log and rate-limit, and can bind the resulting BitID identity to your own user record before the device ever learns anything.
partnerProxyTransport accepts a fetchImpl if you use a pinned/instrumented fetch, and
rejects with a BitIDTransportError carrying .code and .operationName.
The removed direct mode
Earlier drafts documented a second directTransport mode in which the device called BitID's
GraphQL endpoint itself with an x-bitid-service-session header. That mode is gone.
directTransportis a tombstone: calling it throwsBitIDTransportPolicyErrorwithpolicy: 'first-party-only'andcode: 'BITID_TRANSPORT_FIRST_PARTY_ONLY'.- There is no replacement to deep-import. The package ships no module that can build a
device-to-BitID transport — no grant object, no flag, no internal factory — and a test
asserts the string
x-bitid-service-sessionappears in no shipped source file. authHeaders()is for your app session. Any header name matching/service[-_ ]?(account|session)/iis rejected before the request is made, withcode: 'BITID_TRANSPORT_FORBIDDEN_HEADER'. The offending value is never echoed into the error message.
What this does not claim: the SDK is not a security boundary. Nothing stops anyone from
writing their own fetch to any endpoint. The boundary is server-side — BitID only accepts a
service session minted by a registered backend, and a device never holds one. The SDK's job is
to make the correct path the only path it offers, and to fail loudly and legibly when someone
reaches for the old one.
The ports sign-up requires
Sign-up needs exactly two ports. Both are thin relays to your own backend.
import type { TokenizerPort, ClientIpPort } from '@getbitid/react-native';
// The platform rejects a raw phone or email, so values are tokenized first.
// The SDK tokenizes the fields `phoneNumber` and `email`.
const tokenizer: TokenizerPort = {
async tokenize(values) {
const r = await fetch(`${PARTNER_BACKEND}/bitid/tokenize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await appAuthHeaders()) },
body: JSON.stringify(values),
});
if (!r.ok) throw Object.assign(new Error('tokenize failed'), { code: 'ACME_TOKENIZE' });
return r.json();
},
};
// A device cannot observe its own public IP, and account creation requires one.
const clientIp: ClientIpPort = {
async getClientIp() {
const r = await fetch(`${PARTNER_BACKEND}/bitid/client-ip`, { method: 'POST' });
return (await r.json()).ip;
},
};Omit either and the controller throws BITID_PORT_MISSING naming the one that is absent.
riskSignals is optional.
Identity prefill needs no wiring at all. The address and SSN screens are part of sign-up, and the SDK drives the lookup, the SSN check and the address save over your GraphQL proxy itself, the same way it already calls every other sign-up operation.
The service-account key stays on your server. There is no SDK API that accepts one — no
config field, no header hook, nothing. npm run constraints
(scripts/assert-constraints.mjs, also run by npm run verify) fails the package if a
service-account credential name or a bit_sk_-style key literal appears anywhere under src/
or example/ — in tests too. Your app receives only a short-lived session token, minted per
sign-up by your backend, and it travels as a GraphQL variable through your own proxy, never as
a header from the device.
The endpoint contract, with copy-pasteable Node/Express and AWS Lambda implementations, is in
docs/partner-integration.md.
Sign-in does not use the service session. If you only ship sign-in you can omit ports
entirely.
Finishing the flow
useBitIDAuth(config, deps) drives everything. Two config fields decide who redeems the
authorization code:
| config.exchange | Who exchanges the code | You must implement |
| --- | --- | --- |
| 'partner-backend' (default) | your backend | deps.onAuthorizationCode |
| 'device' | the SDK, on-device | nothing — onCompleted gets accessToken |
onAuthorizationCode: async ({ code, codeVerifier, state, redirectUri }) => {
await postToYourBackend('/bitid/exchange', { code, codeVerifier, state, redirectUri });
},
onCompleted: ({ code, accessToken }) => navigate('Home'),Prefer 'partner-backend': the partner token never touches the device, and your server can
persist the link to your own user in the same request.
state is the live BitIDAuthState — { step, flow, busy, error, scopes, profile, … }. You
can build a completely custom UI against controller + state and skip BitIDAuthFlow; the
controller is UI-free and node-tested. createBitIDAuthController is exported for use outside
React.
Theming
Wrap the sheet in BitIDThemeProvider. With no props it uses bitidLightTheme.
import { BitIDThemeProvider, bitidDarkTheme } from '@getbitid/react-native';
<BitIDThemeProvider
theme={scheme === 'dark' ? bitidDarkTheme : bitidLightTheme}
overrides={{
color: { brand: '#0A7C3F', onBrand: '#FFFFFF' },
radius: { button: 12 },
typography: { fontFamily: 'Inter', titleSize: 24 },
}}
>
<BitIDAuthFlow … />
</BitIDThemeProvider>Token groups: color, radius, space, typography, sheet, motion. createBitIDTheme
is the same merge, exported for pre-computing a theme outside React. The exported primitives —
BitIDButton, BitIDTextField, BitIDOtpField, BitIDTitle, BitIDSubtitle,
BitIDInlineError — read the same theme, so your own screens can match the sheet.
Copy
BitIDAuthFlow takes a copy prop; mergeCopy(bitidCopy, overrides) produces one.
import { bitidCopy, mergeCopy } from '@getbitid/react-native';
const copy = mergeCopy(bitidCopy, {
steps: { intro: { title: 'Acme uses BitID to verify you', cta: 'Continue with BitID' } },
});Note: the shipped strings contain {partner} / {destination} placeholders, and
BitIDAuthFlow renders them literally — it does no interpolation today. Override the
steps you show with your own brand name baked in. scopePresentation maps scope ids to
human labels and descriptions if you render your own consent screen.
Errors
Failures land in state.error as { code, message, retryable }, and the sheet's error step
shows the message with a retry button when retryable. Codes are stable BITID_* strings —
the full taxonomy, with the recommended partner action per code, is in
docs/partner-integration.md.
The taxonomy is also exported, so you can branch on descriptors instead of literals:
import { BITID_ERROR_CODES, describeBitIDError, isBitIDErrorCode } from '@getbitid/react-native';
const { category, retryable, partnerAction } = describeBitIDError(state.error.code);describeBitIDError answers for unknown codes too (upstream GraphQL codes, your own port
codes) with a retryable-backend default, so it is safe to call unconditionally.
Errors your own ports throw pass through unchanged if they carry a string .code; anything
else surfaces as BITID_UNEXPECTED.
Device risk signals (Sardine): no extra install
Sign-up sends a device risk-session id to BitID. The lightweight default is
localRiskSignals(), which generates a random id — the flow works, but the signals are not
device-attested.
import { localRiskSignals } from '@getbitid/react-native';
import * as Crypto from 'expo-crypto';
const ports = { serviceSession, riskSignals: localRiskSignals(() => Crypto.randomUUID()) };To send real, device-attested signals, swap in the Sardine-backed port. There is nothing to
npm install — the Sardine binary is vendored inside this package
(ios/vendor/MDI.xcframework and the android/vendor/m2 Maven repo), and the package's own
autolinked native module (BitIDRiskSignals) drives it. You only rebuild the native app:
npx expo prebuild # or: cd ios && pod installimport { createSardineRiskSignals } from '@getbitid/react-native/sardine';
const ports = {
serviceSession,
riskSignals: createSardineRiskSignals({
clientId: BITID_SARDINE_CLIENT_ID,
environment: 'production',
flow: 'signup',
}),
};clientId is the Sardine client id for the BitID integration — a public identifier, handed
to you with your clientId at onboarding. The Sardine API secret is not a device value and
is never accepted by this SDK: it lives on the server that reads the session back from Sardine.
Facts worth knowing before you commit to it:
- It is a native module. It requires
pod install(iOS) / a Gradle sync (Android), so it needs anexpo prebuild+ dev client or a bare workflow. It cannot work in Expo Go. The auth sheet itself still runs in Expo Go — only the risk-signals port needs the native build. - The SDK degrades gracefully.
createSardineRiskSignalslooks the native module up lazily. If it is missing, unlinked, has noclientId, orsetupSDKthrows, it warns once and falls back to the same local session id — sign-up still completes. Nothing throws. getDeviceSignals()reports{ sardineSessionUUID, sardineActive, sardineEnvironment, sardineFlow };sardineActive: falseis how you detect the fallback in the field.- Any environment other than
'production'is normalised to'sandbox'. - Clipboard tracking is off unless you pass
enableClipboardTracking: true.
The SDK ships no analytics of its own — npm run constraints asserts that Mixpanel, MoEngage,
AppsFlyer, and Clarity appear nowhere under src/ or example/.
Other surfaces in this package
The browser button (/signin)
import { SignInWithBitID } from '@getbitid/react-native/signin';
<SignInWithBitID clientId='bitid-partner-acme' onSuccess={(s) => app.signedIn(s.user)} />The OAuth-in-the-system-browser flow from @getbitid/core/signin, wrapped in a button. Needs
expo-web-browser, expo-crypto, expo-linking (lazily imported). Sign-in only — it has no
sign-up path and no service session. Use it when you want the hosted consent page rather than
the native sheet.
The engine (createBitIDReactNative)
import { createBitIDReactNative } from '@getbitid/react-native';
const bitid = createBitIDReactNative({ mode: 'live', fetch: pinnedFetch, camera });
const status = await bitid.wallets.getStatus(userId);Returns the same object as @getbitid/core's createBitID, with the RN ports (httpClient,
reactNativeClock, tieredStorage, intervalCamera, eventLogger) supplied. BitIDProvider,
useBitID, useBitIDStatus, and useAgentOnboarding are exported alongside it. Most of this
plane is Beem-internal today — see the partner guide.
License
UNLICENSED — © Line Financial PBC. All rights reserved.
