Flutter / Dart Shield SDK
The akedly_shield package provides Proof-of-Work solving and Turnstile captcha for Akedly V1.2 in Flutter and Dart. Supports background computation via Dart Isolates and a built-in Turnstile widget.
Distributed via GitHub
akedly_shield is not published on pub.dev yet. Install it as a Git dependency from the official repository.
Installation
Add the Git dependency to your pubspec.yaml:
dependencies:
akedly_shield:
git:
url: https://github.com/Akedly-Org/akedly-shield-dart.git
ref: main
Pin to a specific release by passing a tag instead of main:
dependencies:
akedly_shield:
git:
url: https://github.com/Akedly-Org/akedly-shield-dart.git
ref: 1.1.0
Then fetch the dependency:
flutter pub get
Not on pub.dev
The Flutter SDK is distributed as a GitHub repository only. flutter pub add akedly_shield will not work -- use the Git dependency syntax above.
Quick Start
Never ship your API key to a Flutter app
Flutter builds are shippable binaries — anything embedded becomes public. Keep APIKey and pipelineID on your backend, expose a thin proxy (/auth/akedly/challenge, /auth/akedly/send, /auth/akedly/verify), and have Flutter call the proxy. The Backend tab below shows the minimal Node.js server; the Flutter 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 Flutter 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 Dart — 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.
Import the package and use solvePowInIsolate for Flutter apps (recommended -- runs in a background Isolate to keep the UI responsive).
For Turnstile, add the AkedlyTurnstile widget to your widget tree and receive the token via the onToken callback. The PoW solver and Turnstile widget run on the device; only non-sensitive proofs travel to your backend.
Quick Start
// Express proxy — put this on your server, not in the Flutter app.
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)
Asynchronous PoW solver that yields to the event loop every 10,000 iterations. Runs on the main thread.
- Name
challenge- Type
- String
- Description
64-character hex string from the server challenge response.
- Name
difficulty- Type
- int
- Description
Number of leading hex zeros required.
Returns Future<int> (the nonce).
solvePowInIsolate(challenge, difficulty)
Executes the PoW solver in a separate Dart Isolate. Recommended for Flutter apps to prevent UI thread blocking.
- Name
challenge- Type
- String
- Description
64-character hex string from the server challenge response.
- Name
difficulty- Type
- int
- Description
Number of leading hex zeros required.
Returns Future<int> (the nonce).
AkedlyTurnstile Widget
Flutter widget that loads a Cloudflare Turnstile bridge page via WebView for token generation.
- Name
siteKey- Type
- String (required)
- Description
Cloudflare Turnstile site key from the challenge response.
- Name
onToken- Type
- Function(String) (required)
- Description
Callback that receives the Turnstile token when verification completes.
- Name
onError- Type
- Function(String)?
- Description
Error callback. Called if Turnstile verification fails.
- Name
bridgeDomain- Type
- String?
- Description
Bridge page domain. Defaults to
turnstile.akedly.io.
Complete Example
Full Flutter widget implementing the V1.2 OTP flow against a backend proxy (see the Quick Start for the Node.js backend):
Flutter Widget
import 'package:flutter/material.dart';
import 'package:akedly_shield/akedly_shield.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
// Your backend base URL — keeps APIKey and pipelineID server-side.
const backendUrl = 'https://yourapp.com';
class OTPScreen extends StatefulWidget {
final String phoneNumber;
// Supply the same 4, 5, or 6 value configured by your backend.
final int otpLength;
const OTPScreen({
super.key,
required this.phoneNumber,
required this.otpLength,
});
@override
State<OTPScreen> createState() => _OTPScreenState();
}
class _OTPScreenState extends State<OTPScreen> {
String? _transactionReqID;
String _otp = '';
bool _loading = false;
String? _error;
String? _turnstileSiteKey;
Map<String, dynamic>? _challengeData;
Future<void> _sendOTP({String? turnstileToken}) async {
setState(() { _loading = true; _error = null; });
try {
// 1. Get challenge from YOUR backend
final challengeRes = await http.get(
Uri.parse('$backendUrl/auth/akedly/challenge'),
);
final data = jsonDecode(challengeRes.body)['data'];
// Check if Turnstile is needed and we don't have a token yet
if (data['turnstile']?['required'] == true && turnstileToken == null) {
setState(() {
_challengeData = data;
_turnstileSiteKey = data['turnstile']['siteKey'];
_loading = false;
});
return; // Wait for Turnstile token via widget callback
}
// 2. Solve PoW in background isolate only when the pipeline requires it
Map<String, dynamic>? powSolution;
if (data['challengeRequired'] == true) {
final nonce = await solvePowInIsolate(
data['challenge'] as String,
data['difficulty'] as int,
);
powSolution = {
'challengeToken': data['challengeToken'],
'nonce': nonce,
};
}
// 3. Send proof via YOUR backend
final sendRes = await http.post(
Uri.parse('$backendUrl/auth/akedly/send'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': widget.phoneNumber,
if (powSolution != null) 'powSolution': powSolution,
if (turnstileToken != null) 'turnstileToken': turnstileToken,
}),
);
final result = jsonDecode(sendRes.body);
setState(() { _transactionReqID = result['data']['transactionReqID']; });
} catch (e) {
setState(() { _error = e.toString(); });
} finally {
setState(() { _loading = false; });
}
}
Future<void> _verifyOTP() async {
setState(() { _loading = true; _error = null; });
try {
final res = await http.post(
Uri.parse('$backendUrl/auth/akedly/verify'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'transactionReqID': _transactionReqID, 'otp': _otp}),
);
final result = jsonDecode(res.body);
if (result['status'] == 'success') {
if (mounted) Navigator.of(context).pop(true);
} else {
setState(() { _error = 'Invalid OTP'; });
}
} catch (e) {
setState(() { _error = e.toString(); });
} finally {
setState(() { _loading = false; });
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Verify Phone')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_turnstileSiteKey != null && _transactionReqID == null)
SizedBox(
height: 1,
child: AkedlyTurnstile(
siteKey: _turnstileSiteKey!,
onToken: (token) => _sendOTP(turnstileToken: token),
onError: (err) => setState(() { _error = err; }),
),
),
if (_transactionReqID == null) ...[
Text('Send OTP to ${widget.phoneNumber}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loading ? null : () => _sendOTP(),
child: Text(_loading ? 'Sending...' : 'Send OTP'),
),
] else ...[
TextField(
onChanged: (v) => setState(() { _otp = v; }),
decoration: const InputDecoration(labelText: 'Enter OTP'),
keyboardType: TextInputType.number,
maxLength: otpLength,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loading || _otp.length < otpLength ? null : _verifyOTP,
child: Text(_loading ? 'Verifying...' : 'Verify'),
),
],
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(_error!, style: const TextStyle(color: Colors.red)),
),
],
),
),
);
}
}
Platform Support
The Flutter Shield SDK works on Android, iOS, Web, and Desktop (macOS, Windows, Linux). solvePowInIsolate is available on all platforms. AkedlyTurnstile requires WebView support (Android, iOS, Web).
Passkeys
Flutter apps run the V1.2 passkey ceremony hosted at auth.akedly.io/pk, opened in a system authentication session via flutter_web_auth_2 — ASWebAuthenticationSession on iOS, a Custom Tab on Android. Because the ceremony runs on the akedly.io origin inside a real system browser session, platform passkeys (Face ID / Touch ID / fingerprint / device PIN) work with no embedded WebView and no associated-domains / Digital Asset Links setup. The result returns to your app through a deep-link custom scheme.
Full endpoint contract: V1.2 Passkeys.
AkedlyPasskey.openCeremony is a thin wrapper over flutter_web_auth_2, so you add that package and register your custom scheme on Android — an <intent-filter> on the package's CallbackActivity (the standard flutter_web_auth_2 setup). iOS needs no setup: flutter_web_auth_2 uses ASWebAuthenticationSession there, which receives the callback through its own API — no Info.plist URL scheme is required. Replace myapp below with your own scheme.
Register the callback scheme
dependencies:
akedly_shield:
git:
url: https://github.com/Akedly-Org/akedly-shield-dart.git
ref: main
flutter_web_auth_2: any
Enroll after a verify
A successful OTP /verify returns an additive enrollmentToken. Offer enrollment immediately by passing it to AkedlyPasskey.openCeremony as the token — the API is identical to the auth case; enrollment is proven on the next successful sign-in.
Enroll a passkey
import 'package:akedly_shield/akedly_shield.dart';
// After an OTP /verify that returned data.enrollmentToken:
// For result.verified to be true here, the OTP /verify that minted enrollmentToken must
// also have passed returnTarget: { "url": "myapp://akedly-passkey" }. Without it enrollment
// still succeeds, but this resolves verified:false / no_proof — treat it as advisory.
final result = await AkedlyPasskey.openCeremony(
token: enrollmentToken,
callbackScheme: 'myapp',
);
// result.verified == true => the passkey now exists (proven on the next sign-in).
Authenticate a returning user
Your backend clears the gate and starts the ceremony with POST /transactions/passkey/auth-options, which returns 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. That call runs the same PoW/Turnstile Shield chain as /send. New pipelines default PoW to enabled and Turnstile to disabled: pass 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. Hand the token to AkedlyPasskey.openCeremony. The call resolves with an AkedlyPasskeyResult.
Authenticate with a passkey
import 'package:akedly_shield/akedly_shield.dart';
// 1. Your backend clears the gate + starts the ceremony:
// POST /api/v1.2/transactions/passkey/auth-options
// { APIKey, pipelineID, verificationAddress: { phoneNumber }, powSolution?, turnstileToken?,
// returnTarget: { url: "myapp://akedly-passkey" } } // <-- REQUIRED for the resultToken
// -> { data: { ceremonyToken, requestID } }
final auth = await myBackend.startPasskeyAuth(phone);
if (auth == null) return runOtpFallback(); // 404 NO_PASSKEY -> use OTP
// 2. Run the hosted ceremony in a system auth session.
final AkedlyPasskeyResult result = await AkedlyPasskey.openCeremony(
token: auth.ceremonyToken,
callbackScheme: 'myapp',
// ceremonyOrigin: 'https://localhost:5174', // shared-cert local QA
);
if (result.verified) {
// 3. Confirm offline on YOUR backend without waiting for polling/callback. See below.
await myBackend.completeSignIn(
result.resultToken!,
expectedRequestID: auth.requestID,
);
} else if (result.reason == 'no_proof') {
// The authentication may have SUCCEEDED and been billed — the relay just carried no proof.
// Reconcile /result or the backend callback BEFORE starting OTP, or you charge twice.
await reconcileThenMaybeOtp(auth.requestID);
} else {
await runOtpFallback();
}
Fall back to OTP
Every non-verified outcome resolves with verified == false and a reason. For authentication
no_proof, reconcile /result or the correlated backend callback before starting OTP because
verification may already be settled. Other failures can branch straight to your existing OTP
send / verify.
- Name
verified- Type
- bool
- Description
trueonly on a completed, successful ceremony. A verified result always carries aresultToken.
- Name
resultToken- Type
- String?
- Description
The HMAC-signed proof of the outcome (
pkrt1.…). Forward it to your backend and verify it offline — see below.nullwhen not verified.
- Name
reason- Type
- String?
- Description
nullwhen verified; otherwiseclosed(user cancelled) |start_failed|no_proof(claimed or recovered success without aresultToken) |failed. Reconcile the transaction before falling back to OTP.
Verify the result
A verified ceremony returns a resultToken; forward it to your backend and verify it there before creating a session. Never embed your API key in the Flutter app. Use the hub verifier for token format, HMAC validation, callback behavior, and replay rules.
Without the SDK
AkedlyPasskey is a thin wrapper over flutter_web_auth_2. The ceremony is just a URL you open in an auth session; the result comes back on your scheme:
https://auth.akedly.io/pk?token=&returnUrl=myapp://akedly-passkey
-> redirects to: myapp://akedly-passkey?verified=true&transactionId=…
(resultToken ONLY if your backend signed returnTarget into the ceremony token)
AkedlyPasskey.buildUrl(...) and AkedlyPasskey.parseResult(uri) / AkedlyPasskey.parseResultFromQuery(query) are public if you want them without the session wrapper.
The returnUrl query param selects where the ceremony redirects; it does not authorize the proof. A query-supplied target is always untrusted, so that redirect carries no resultToken and the SDK reports verified: false / no_proof even on a fully successful ceremony. To receive the proof your backend must sign a returnTarget into the ceremony token via /auth-options (or /verify when enrolling).
