Akedly

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

BASH
npm install @akedly/shield

For Turnstile support, you also need a WebView package:

npm install react-native-webview

Key Differences from Web

FeatureWebReact Native
PoW SolverWeb Worker (off-thread)Batched main-thread with setTimeout(fn, 0)
UI during PoWFully responsiveResponsive (batched yields to event loop)
TurnstilegetTurnstileToken() (hidden DOM widget)WebView pointing to bridge page
ImportSameSame

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

React Native Component

JS
OTPScreen.js
// 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 result envelope

On a terminal outcome the /pk page calls window.ReactNativeWebView.postMessage(JSON). Your onMessage handler receives the JSON string in event.nativeEvent.dataJSON.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

    true only on a completed, successful ceremony.

  • Name
    code
    Type
    string | null
    Description

    Reserved — currently always null. The /pk page never populates it today (a future server code may appear here). Branch on verified only; do not key logic off code.

  • 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 reports null here; its OTP transaction binding lives inside the signed resultToken and 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 a verified:true with no resultToken as 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

JSX
PasskeyScreen.js
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.


Was this page helpful?