Web / JavaScript Shield SDK
The @akedly/shield package provides Proof-of-Work solving, Turnstile helpers, and the passkey ceremony launcher for Akedly V1.2. Works in browsers (with Web Worker), React Native (batched main-thread), and Node.js (sync crypto).
Installation
Installation
npm install @akedly/shield
Or via CDN (no build step required):
<script src="https://unpkg.com/@akedly/shield/dist/akedly-shield.min.js"></script>
When loaded via CDN, all exports are available on the global AkedlyShield object:
const { solvePow, getTurnstileToken } = window.AkedlyShield;
Quick Start
Never ship your API key to the browser
Anything in your bundle is public — anyone can read it in DevTools. Keep APIKey and pipelineID on your backend and expose a thin proxy (/auth/akedly/challenge, /auth/akedly/send, /auth/akedly/verify) that the browser calls. The Backend tab below shows the minimal Node.js server; the Frontend tab shows the matching client. See the Next.js section further down for a server-actions variant.
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 browser, 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, Vercel). Skip this entirely if you don't need per-IP limiting — phone and pipeline limits always apply. See the V1.2 API reference for Next.js, Flask, and PHP extraction patterns.
The complete V1.2 flow using the Shield SDK:
- Your backend exposes a challenge proxy
- The client calls the proxy, then solves PoW with
solvePow() - If required, the client gets a Turnstile token via
getTurnstileToken() - The client posts the proof to the backend, which forwards it to Akedly
- The client submits the OTP via the backend verify proxy
Complete Flow
// Express proxy — put this on your server, not in the browser 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());
});
API Methods
solvePow(challenge, difficulty, options?)
Convenience function that creates a solver, finds the nonce, and cleans up.
- Name
challenge- Type
- string
- Description
64-character hex string from the server challenge response.
- Name
difficulty- Type
- number
- Description
Number of leading hex zeros required in the hash.
- Name
options.onProgress- Type
- function
- Description
Callback receiving the number of hashes checked so far. Useful for progress indicators.
- Name
options.useWorker- Type
- 'auto' | true | false
- Description
Controls Web Worker usage.
'auto'(default) detects availability. Setfalsefor Node.js or React Native.
Returns Promise<{ nonce: number }>.
new AkedlyShield(options?)
Reusable solver instance. Use when making multiple solve calls to avoid re-creating Workers.
- Name
options.useWorker- Type
- 'auto' | true | false
- Description
Controls Web Worker usage. Default:
'auto'.
Instance methods:
- Name
.solve(challenge, difficulty, options?)- Type
- method
- Description
Same as
solvePowbut reuses the Worker instance. ReturnsPromise<{ nonce: number }>.
- Name
.terminate()- Type
- method
- Description
Kills the active Web Worker and releases the Blob URL. Call when done with the instance.
getTurnstileToken(siteKey, options?)
Browser-only. Creates a hidden Cloudflare Turnstile widget, resolves with the token, and cleans up.
- Name
siteKey- Type
- string
- Description
Cloudflare Turnstile site key from the challenge response.
- Name
options.theme- Type
- 'light' | 'dark' | 'auto'
- Description
Turnstile widget theme. Default:
'auto'.
- Name
options.size- Type
- 'normal' | 'compact'
- Description
Widget size. Default:
'normal'.
Returns Promise<string> (the Turnstile token).
renderTurnstile(siteKey, container, options?)
Renders a Turnstile widget into a specific DOM element. Use when you want visible Turnstile placement.
- Name
siteKey- Type
- string
- Description
Cloudflare Turnstile site key.
- Name
container- Type
- HTMLElement
- Description
DOM element to render the widget into.
Returns Promise<string> (the Turnstile token).
openPasskeyCeremony(token, options?)
Browser-only. Opens the hosted V1.2 passkey ceremony (auth.akedly.io/pk) as a popup and resolves with the result the page relays back. Use it to enroll (pass the enrollmentToken from a successful /verify) and to authenticate (pass the ceremonyToken your backend gets from /auth-options).
Call inside the click gesture
window.open must run in the direct user-gesture handler or the popup is blocked. If you need a backend round-trip to mint the token first (the auth case), open a blank popup in the handler and pass it via options.popup.
- Name
token- Type
- string
- Description
A ceremony token minted by your backend — the
enrollmentTokento enroll, or theceremonyTokento authenticate.
- Name
options.ceremonyOrigin- Type
- string
- Description
Override the hosted origin. Default
https://auth.akedly.io.
- Name
options.popup- Type
- Window
- Description
A window you opened synchronously in the click; the SDK navigates it instead of opening its own.
- Name
options.onResult- Type
- function
- Description
Side-channel callback receiving the same payload as the resolved value.
- Name
options.onTelemetry- Type
- function
- Description
Fire-and-forget funnel events (
passkey_opened,passkey_verified,passkey_cancelled, …).
- Name
options.windowFeatures- Type
- string
- Description
window.openfeature string for the popup. Defaultwidth=480,height=680,menubar=no,toolbar=no,location=no,status=no.
- Name
options.windowName- Type
- string
- Description
Target name for the popup window. Default
akedly_passkey.
- Name
options.timeoutMs- Type
- number
- Description
Auto-resolve with
reason: 'timeout'after N ms. Default0(no timeout).
Returns Promise<PasskeyCeremonyResult>:
{
verified: boolean // true only on a completed, successful ceremony
supported: boolean // false only when WebAuthn is unavailable
purpose: string | null // 'enroll' | 'auth'
transactionId: string | null
resultToken: string | null // signed proof of a verified outcome — verify on YOUR backend
code: string | null // reserved — currently always null; branch on `verified`/`reason`
reason: string | null // null when verified; else 'closed' | 'popup_blocked'
// | 'unsupported' | 'timeout' | 'no_proof' | 'failed'
}
The promise never rejects on a normal outcome — it resolves with a reason so you can fall back to OTP. It rejects only on a missing token.
isPasskeySupported()
Synchronous boolean — true when the browser exposes window.PublicKeyCredential. Use it to skip the passkey offer on unsupported browsers and fall straight through to OTP.
Passkeys
openPasskeyCeremony is a stateless launcher — it never calls /challenge, /send, or /verify; you own those. The hosted auth.akedly.io/pk page runs the WebAuthn ceremony (branded from your pipeline) and relays the result back. Always confirm server-side.
Full endpoint contract: V1.2 Passkeys.
Enroll after a verify
A successful /verify returns an additive enrollmentToken. Offer enrollment immediately:
Enroll a passkey
import { openPasskeyCeremony, isPasskeySupported } from '@akedly/shield'
if (verify.data.enrollmentToken && isPasskeySupported()) {
// For result.verified to be true here, the OTP /verify that minted enrollmentToken must
// also have passed returnTarget: { origin: 'https://your-site.example' }. Without it
// enrollment still succeeds, but this resolves verified:false / no_proof — treat it as advisory.
// In the click handler of an "Enable Face ID / fingerprint" button:
const result = await openPasskeyCeremony(verify.data.enrollmentToken)
// result.verified === true => the passkey now exists (proven on next sign-in).
}
Authenticate a returning user
Your backend calls POST /transactions/passkey/auth-options for a ceremonyToken and requestID. A 404 with code: "NO_PASSKEY" is the availability check — fall back 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. For the browser flow, include returnTarget: { origin: "<your site origin>" }. Because auth-options is a round-trip, open the popup in the gesture and hand it over:
Authenticate with a passkey
// Your backend proxy → POST /transactions/passkey/auth-options with:
// { APIKey, pipelineID, verificationAddress: { phoneNumber },
// powSolution, turnstileToken, // <-- same bot proofs as /send (see note above)
// returnTarget: { origin: 'https://your-site.example' } } // <-- REQUIRED for the web resultToken
// In the sign-in click handler:
const popup = window.open('about:blank', 'akedly_passkey', 'width=480,height=680')
const { ceremonyToken, requestID } = await startAuth(phoneNumber) // keep requestID for proof binding
if (!ceremonyToken) { popup?.close(); return fallbackToOtp() } // 404 NO_PASSKEY
const result = await openPasskeyCeremony(ceremonyToken, { popup })
if (result.verified) {
await completeSignIn(result.resultToken, requestID) // backend verifies + binds the proof
} else if (result.reason === 'no_proof') {
// The authentication may have SUCCEEDED and been billed — the relay just carried no proof.
// Reconcile via GET /result or the backend callback BEFORE starting OTP, or you charge twice.
await reconcileThenMaybeOtp(requestID)
} else {
await fallbackToOtp()
}
Web: set returnTarget.origin or the resultToken is stripped
The hosted page will never postMessage a signed resultToken to an unknown web origin.
When your /auth-options call omits returnTarget.origin, the popup relays to "*" and the
gateway drops the token. The raw envelope's UX fields still arrive, but the SDK refuses to
call an unproven result verified and demotes it: you get result.verified === false,
result.reason === "no_proof" and result.resultToken === null — so an
if (result.verified) branch will not fire, and a plain else sends a billed, successful
authentication down your OTP fallback. Pass
returnTarget: { origin: window.location.origin-equivalent } from your backend so the token is
relayed. Enrollment uses the same rule: pass returnTarget to OTP /verify when you need a
proven enrollment result. Without it, enrollment succeeds but the SDK reports
verified: false, reason: "no_proof".
Fall back to OTP
Every non-verified outcome resolves a reason — closed, popup_blocked, unsupported,
timeout, no_proof, or failed. no_proof means the ceremony claimed success but the relay
did not carry a non-blank resultToken. Deny access from that relay. For authentication,
reconcile /result or the correlated backend callback before starting OTP because the passkey
verification may already be settled. Other failures can branch straight to your OTP flow.
Verify the result
A verified: true message only unblocks your UI — forward result.resultToken to your backend and verify it there before creating a session. Use the hub verifier for token format, HMAC validation, callback behavior, and replay rules.
A complete, runnable reference app (Node backend + browser front end exercising enroll → authenticate → OTP fallback, verifying the resultToken offline) lives in the Akedly backend repo (private) — contact support@akedly.io if you'd like a copy.
Web Worker Behavior
When useWorker is 'auto' (default), the library:
- Checks if
Worker,Blob, andURL.createObjectURLare available - If yes, bundles the solver into a Blob URL Worker (no separate file needed)
- If no (e.g., React Native), falls back to batched main-thread solving with
setTimeout(fn, 0)between batches to keep the UI responsive
The Web Worker approach runs PoW solving entirely off the main thread, preventing UI freezes during computation.
Platform Support
| Platform | PoW Solver | Turnstile |
|---|---|---|
| Browser (Worker) | Web Worker (off-thread) | getTurnstileToken() |
| Browser (no Worker) | Batched main-thread | getTurnstileToken() |
| React Native | Batched main-thread | Use bridge page |
| Node.js | Sync with native crypto | N/A |
Framework Examples
React
React Component
import { useState } from 'react';
import { solvePow, getTurnstileToken } from '@akedly/shield';
// This React component calls YOUR backend proxy (see Quick Start for the Node.js server).
// Never put AKEDLY_API_KEY or AKEDLY_PIPELINE_ID in REACT_APP_* — those ship to the browser.
// Pass the same 4, 5, or 6 value that your backend requests from Akedly.
// The UI length is not sent to Akedly and must not be hardcoded independently.
function OTPAuth({ phoneNumber, otpLength, onSuccess }) {
const [transactionReqID, setTransactionReqID] = useState(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSendOTP = async () => {
setLoading(true);
setError(null);
try {
// Get challenge from YOUR backend
const challengeRes = await fetch('/auth/akedly/challenge');
const { data } = await challengeRes.json();
// Solve PoW only when the pipeline requires it.
let powSolution;
if (data.challengeRequired) {
const { nonce } = await solvePow(data.challenge, data.difficulty);
powSolution = { challengeToken: data.challengeToken, nonce };
}
// Get Turnstile token
const turnstileToken = data.turnstile?.required
? await getTurnstileToken(data.turnstile.siteKey)
: undefined;
// Send proof via YOUR backend
const sendRes = await fetch('/auth/akedly/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phoneNumber,
...(powSolution ? { powSolution } : {}),
turnstileToken,
}),
});
const result = await sendRes.json();
setTransactionReqID(result.data.transactionReqID);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleVerify = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/auth/akedly/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionReqID, otp }),
});
const result = await res.json();
if (result.status === 'success') onSuccess(result);
else setError('Invalid OTP');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!transactionReqID) {
return (
<div>
<button onClick={handleSendOTP} disabled={loading}>
{loading ? 'Sending...' : 'Send OTP'}
</button>
{error && <p>{error}</p>}
</div>
);
}
return (
<div>
<input
value={otp}
onChange={(e) => setOtp(e.target.value)}
placeholder={`Enter ${otpLength}-digit OTP`}
maxLength={otpLength}
/>
<button onClick={handleVerify} disabled={loading || otp.length < otpLength}>
{loading ? 'Verifying...' : 'Verify'}
</button>
{error && <p>{error}</p>}
</div>
);
}
export default OTPAuth;
Next.js (Server Action + Client)
Next.js
'use server';
import { headers } from 'next/headers';
export async function getChallenge() {
const res = await fetch(
`https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=${process.env.AKEDLY_API_KEY}&pipelineID=${process.env.AKEDLY_PIPELINE_ID}`
);
return res.json();
}
export async function sendOTP(phoneNumber, powSolution, turnstileToken) {
// Extract the END USER's IP (not this server's). Cloudflare sets
// cf-connecting-ip; Vercel/ALBs set x-forwarded-for (first entry is the client).
const h = await headers();
const endUserIP =
h.get('cf-connecting-ip') ||
h.get('x-real-ip') ||
h.get('x-forwarded-for')?.split(',')[0].trim();
const res = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(endUserIP && { 'x-end-user-ip': endUserIP }),
},
body: JSON.stringify({
APIKey: process.env.AKEDLY_API_KEY,
pipelineID: process.env.AKEDLY_PIPELINE_ID,
verificationAddress: { phoneNumber },
powSolution,
turnstileToken
})
});
return res.json();
}
export async function verifyOTP(transactionReqID, otp) {
const res = await fetch('https://api.akedly.io/api/v1.2/transactions/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionReqID, otp }),
});
return res.json();
}
Vanilla JavaScript (CDN)
Still calls your backend proxy — only the Shield SDK is loaded from the CDN. Do not put your API key in a <script> tag.
CDN Usage
<script src="https://unpkg.com/@akedly/shield/dist/akedly-shield.min.js"></script>
<script>
const { solvePow, getTurnstileToken } = window.AkedlyShield;
async function authenticate(phone) {
// 1. Get challenge from YOUR backend
const res = await fetch('/auth/akedly/challenge');
const { data } = await res.json();
// 2. Solve PoW + (optional) Turnstile in the browser
let powSolution;
if (data.challengeRequired) {
const { nonce } = await solvePow(data.challenge, data.difficulty);
powSolution = { challengeToken: data.challengeToken, nonce };
}
const turnstileToken = data.turnstile?.required
? await getTurnstileToken(data.turnstile.siteKey)
: undefined;
// 3. Send proof via YOUR backend
const sendRes = await fetch('/auth/akedly/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phoneNumber: phone,
...(powSolution ? { powSolution } : {}),
turnstileToken,
}),
});
return sendRes.json();
}
</script>
