React Native Shield SDK
React Native uses the same @akedly/shield npm package as web. The library automatically detects the React Native environment and switches to batched main-thread PoW solving (no Web Worker).
Installation
Installation
npm install @akedly/shield
For Turnstile support, you also need a WebView package:
npm install react-native-webview
Key Differences from Web
| Feature | Web | React Native |
|---|---|---|
| PoW Solver | Web Worker (off-thread) | Batched main-thread with setTimeout(fn, 0) |
| UI during PoW | Fully responsive | Responsive (batched yields to event loop) |
| Turnstile | getTurnstileToken() (hidden DOM widget) | WebView pointing to bridge page |
| Import | Same | Same |
The solvePow function works identically -- it detects the environment and falls back to batched solving automatically. No configuration needed.
For Turnstile, getTurnstileToken() is browser-only. In React Native, use a WebView pointing to the Turnstile bridge page at turnstile.akedly.io.
Complete Example
Never ship your API key to React Native
JS bundles in React Native apps are inspectable — anything shipped is public. Keep APIKey and pipelineID on your backend and expose a thin proxy (/auth/akedly/challenge, /auth/akedly/send, /auth/akedly/verify) that the RN app calls. The Backend tab below shows the minimal Node.js server; the React Native tab shows the matching client.
Optional: per-end-user-IP rate limiting
Per-IP rate limiting is opt-in. If you want it, forward the end user's IP via the x-end-user-ip header — the IP that hit your backend from the mobile device, not your server's IP. The Express example below uses req.ip, which only returns the real client IP after app.set('trust proxy', 1) is configured for your reverse proxy (Cloudflare, AWS ALB, Nginx, Fly.io). Do not read the device's public IP from within the RN bundle — clients cannot reliably know their own public IP and can lie. Skip this entirely if you don't need per-IP limiting. See the V1.2 API reference for Next.js, Flask, and PHP extraction patterns.
React Native Component
// Express proxy — deploy this on your server, not inside the RN bundle.
import express from 'express';
const app = express();
app.use(express.json());
// Optional — only needed if you want per-end-user-IP rate limiting.
// Drop this line and the x-end-user-ip header below if you don't.
// Makes req.ip the real client IP behind a reverse proxy (1 = trust first hop).
app.set('trust proxy', 1);
app.get('/auth/akedly/challenge', async (_req, res) => {
const r = await fetch(
`https://api.akedly.io/api/v1.2/transactions/challenge` +
`?APIKey=${process.env.AKEDLY_API_KEY}` +
`&pipelineID=${process.env.AKEDLY_PIPELINE_ID}`
);
res.status(r.status).json(await r.json());
});
app.post('/auth/akedly/send', async (req, res) => {
const { phoneNumber, powSolution, turnstileToken } = req.body;
const r = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-end-user-ip': req.ip,
},
body: JSON.stringify({
APIKey: process.env.AKEDLY_API_KEY,
pipelineID: process.env.AKEDLY_PIPELINE_ID,
verificationAddress: { phoneNumber },
powSolution,
turnstileToken,
}),
});
res.status(r.status).json(await r.json());
});
app.post('/auth/akedly/verify', async (req, res) => {
const { transactionReqID, otp } = req.body;
const r = await fetch('https://api.akedly.io/api/v1.2/transactions/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionReqID, otp }),
});
res.status(r.status).json(await r.json());
});
Passkeys
In React Native you run the hosted V1.2 passkey ceremony by loading https://auth.akedly.io/pk?token=… in a react-native-webview <WebView>. WebAuthn runs on the akedly.io origin inside the system WebView; the page relays the result back to RN through window.ReactNativeWebView.postMessage, which fires the WebView's onMessage handler. There is no native passkey SDK — the WebView bridge is the integration.
Full endpoint contract: V1.2 Passkeys.
The WebView bridge gets the proof; the deep-link fallback does not
The ReactNativeWebView bridge receives the full envelope including resultToken, because the host app owns the WebView — no returnTarget needed. The deep-link fallback described below is different: it is an ordinary redirect, and /pk releases a resultToken on a redirect only to a server-signed target. If you take that route, your backend must pass returnTarget: { "url": "myapp://akedly-passkey" } to /auth-options, or the ceremony will succeed while the SDK reports verified: false / no_proof. See Verify resultToken.
Always keep the OTP fallback
Platform passkey availability inside an RN WebView varies by OS and version. Keep the OTP path (above) as a fallback for every outcome that isn't verified. If you need a guaranteed system-browser ceremony, open /pk via an in-app browser (expo-web-browser / react-native-inappbrowser-reborn) with a deep-link returnUrl=myapp://akedly-passkey instead — the result is then captured off the redirect query rather than a postMessage.
The result envelope
On a terminal outcome the /pk page calls window.ReactNativeWebView.postMessage(JSON). Your onMessage handler receives the JSON string in event.nativeEvent.data — JSON.parse it, then match on type before trusting anything:
- Name
type- Type
- string
- Description
Always
"AKEDLY_PASSKEY_RESULT". Ignore any message where this doesn't match — the WebView surfaces other postMessages too.
- Name
purpose- Type
- 'enroll' | 'auth'
- Description
Which ceremony ran.
- Name
verified- Type
- boolean
- Description
trueonly on a completed, successful ceremony.
- Name
code- Type
- string | null
- Description
Reserved — currently always
null. The/pkpage never populates it today (a future server code may appear here). Branch onverifiedonly; do not key logic offcode.
- Name
transactionId- Type
- string | null
- Description
For authentication, the passkey
requestID; bind it to the sign-in you started by checking it matches. Enrollment currently reportsnullhere; its OTP transaction binding lives inside the signedresultTokenand is verified by your backend.
- Name
resultToken- Type
- string | null
- Description
Signed proof of a verified outcome (
pkrt1.…) — the authoritative trust path. Forward it to your backend and verify it there. A verified result always carries one; treat averified:truewith noresultTokenas untrusted.
Enroll after a verify
A successful V1.2 /verify returns an additive enrollmentToken (valid ~2 min, single use). Load /pk?token=<enrollmentToken> in the WebView to offer enrollment immediately. Enrollment has no /result to poll — once the ceremony reports verified, the passkey simply exists and is proven on the user's next sign-in.
Authenticate a returning user
Your backend starts the ceremony by calling POST /transactions/passkey/auth-options (it carries the secret API key and clears the same PoW/Turnstile Shield gate as /send). New pipelines default PoW to enabled and Turnstile to disabled: pass the same powSolution when PoW is required and turnstileToken when Turnstile is enabled, and omit each control when it is not required or its explicit dev-mode bypass is enabled. It returns a ceremonyToken and requestID. A 404 with code: "NO_PASSKEY" is the availability check — fall through to OTP. A 403 with code: "PASSKEY_DISABLED" takes the same OTP fallback, but means passkeys are off for this account or this pipeline (the response does not say which) — see V1.2 Passkeys. Hand both values to the app, which loads /pk?token=<ceremonyToken> in the WebView and preserves requestID for proof binding.
Passkey ceremony in a WebView
import React from 'react';
import { View } from 'react-native';
import { WebView } from 'react-native-webview';
// Point this at your own backend. No API key. No pipeline ID.
const BACKEND_URL = 'https://yourapp.com';
// `pkToken` is an enrollmentToken (enroll) or a ceremonyToken (auth) that your
// backend already minted — see "Enroll after a verify" / "Authenticate a returning user".
export default function PasskeyScreen({ pkToken, expectedRequestID, onEnrolled, onSignedIn, onFallback }) {
const pkUrl = `https://auth.akedly.io/pk?token=${encodeURIComponent(pkToken)}`;
const handleMessage = async (event) => {
let msg;
try {
msg = JSON.parse(event.nativeEvent.data);
} catch {
return; // not JSON — ignore
}
if (msg.type !== 'AKEDLY_PASSKEY_RESULT') return; // not our message
if (msg.purpose === 'enroll') {
// Enrollment confirms setup; it must never create an authenticated app session.
if (msg.verified && msg.resultToken) {
const res = await fetch(`${BACKEND_URL}/complete-enrollment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resultToken: msg.resultToken }),
});
const confirm = res.ok ? await res.json().catch(() => null) : null;
if (confirm?.verified && confirm?.purpose === 'enroll') {
onEnrolled?.({ transactionId: confirm.transactionId });
} else {
onFallback(null);
}
} else {
onFallback(msg.code);
}
return;
}
if (msg.purpose !== 'auth') return;
if (msg.verified && msg.resultToken) {
// Seamless + synchronous: hand the signed proof to YOUR backend, which
// verifies it offline without waiting for polling/callback and creates the session.
const res = await fetch(`${BACKEND_URL}/complete-sign-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resultToken: msg.resultToken,
expectedRequestID,
}),
});
const confirm = res.ok ? await res.json().catch(() => null) : null;
if (confirm?.verified) {
onSignedIn(msg.transactionId);
} else {
onFallback(null); // backend rejected the proof -> fall back to OTP
}
} else {
// Not verified (msg.code is reserved — currently always null) -> fall back to OTP.
onFallback(msg.code);
}
};
return (
<View style={{ flex: 1 }}>
<WebView
source={{ uri: pkUrl }}
onMessage={handleMessage}
javaScriptEnabled={true}
domStorageEnabled={true}
originWhitelist={['https://auth.akedly.io']}
/>
</View>
);
}
Verify the result
A verified:true message only unblocks your UI. Forward msg.resultToken to your backend and verify it there before creating a session. Never embed your API key in the React Native bundle. Use the hub verifier for token format, HMAC validation, callback behavior, and replay rules.
Without extra deps
The <WebView> plus its onMessage handler is the entire integration — there is no native passkey SDK to install, and @akedly/shield is not involved on this path (solvePow still runs your OTP flow; passkeys are a pure WebView relay). You already added react-native-webview for Turnstile, so nothing new is required.
If you hit an OS/WebView combination that gates WebAuthn inside an embedded WebView, open /pk in an in-app browser tab instead — expo-web-browser or react-native-inappbrowser-reborn — and pass a deep-link returnUrl=myapp://akedly-passkey. The /pk page then redirects to myapp://akedly-passkey?verified=true&transactionId=…; read the result off the captured deep-link query and verify it on your backend exactly as above. Unlike the WebView bridge, this route is an ordinary redirect and is trust-gated: the resultToken rides only when your backend signed returnTarget: { "url": "myapp://akedly-passkey" } into the ceremony token at /auth-options. Omit it and the redirect arrives token-stripped, with no postMessage bridge to fall back on.
Expo Considerations
If using Expo, install the WebView via:
npx expo install react-native-webview
The @akedly/shield package works with Expo without any additional configuration. The PoW solver uses JavaScript-only crypto (no native modules required).
For managed Expo projects, the Turnstile WebView approach works out of the box since react-native-webview is supported in the Expo Go client.
