iOS / Swift Shield SDK
AkedlyShield provides Proof-of-Work solving and Turnstile token retrieval for Akedly V1.2 on iOS and macOS. Uses Swift concurrency (async/await) and CryptoKit for native performance.
Installation
Add via Swift Package Manager in Xcode:
- File > Add Package Dependencies
- Enter:
https://github.com/Akedly-Org/akedly-shield-swift - Select version
1.1.0or later
Or add to Package.swift:
dependencies: [
.package(url: "https://github.com/Akedly-Org/akedly-shield-swift", from: "1.1.0")
]
Requirements: iOS 13+ (CryptoKit)
Quick Start
Never ship your API key to an iOS app
App bundles can be inspected — anything compiled in is public. Keep APIKey and pipelineID on your backend and expose a thin proxy (/auth/akedly/challenge, /auth/akedly/send, /auth/akedly/verify) for the iOS app to call. The Backend tab below shows the minimal Node.js server; the Swift 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 iOS 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 iOS app — 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 AkedlyShield and use the async solvePow function to solve challenges on a background thread. For Turnstile, use AkedlyTurnstile which creates a hidden WKWebView. PoW and Turnstile run on-device; only non-sensitive proofs travel to your backend.
Quick Start
// Express proxy — deploy this on your server, not inside the iOS 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:) async -> Int
Asynchronous PoW solver using Swift concurrency. Executes on a background thread via Task.detached and yields every 10,000 iterations for cooperative cancellation.
- Name
challenge- Type
- String
- Description
64-character hex string from the server.
- Name
difficulty- Type
- Int
- Description
Number of leading hex zeros required.
Returns Int (the nonce).
solvePowSync(challenge:difficulty:) -> Int
Synchronous blocking solver. Use on background queues only.
- Name
challenge- Type
- String
- Description
64-character hex string from the server.
- Name
difficulty- Type
- Int
- Description
Number of leading hex zeros required.
Returns Int (the nonce). Blocks until found.
Blocking
solvePowSync blocks the calling thread. Never call on the main thread. Use solvePow (async) for most cases.
AkedlyTurnstile
Creates a hidden WKWebView to retrieve Cloudflare Turnstile tokens.
- Name
init(bridgeDomain:)- Type
- constructor
- Description
Optional bridge domain. Defaults to
turnstile.akedly.io.
- Name
getToken(siteKey:) async throws -> String- Type
- method
- Description
Loads the Turnstile bridge page and returns the verification token.
Passkeys
The iOS app runs a hosted V1.2 passkey ceremony at auth.akedly.io/pk inside an ASWebAuthenticationSession. The session shares the system credential store, so platform passkeys work with no embedded WebView, passkey entitlement, or associated-domains setup. The result returns through a custom URL scheme.
Full endpoint contract: V1.2 Passkeys.
Enroll after a verify
A successful OTP /verify returns an additive enrollmentToken. Pass it straight to openPasskeyCeremony to offer enrollment — the API is identical to authentication, and the passkey is proven on the next successful sign-in.
Enroll a passkey
import AkedlyShield
// `enrollmentToken` comes from a successful OTP /verify (relayed by YOUR backend).
// For `result.verified` to be true here, that /verify call 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.
let passkey = AkedlyPasskey()
let result = await passkey.openPasskeyCeremony(token: enrollmentToken, callbackScheme: "myapp")
if result.verified {
// The passkey now exists — it is proven on the next sign-in.
}
Authenticate a returning user
Your backend clears the V1.2 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 openPasskeyCeremony:
Authenticate with a passkey
import AkedlyShield
// 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 } } (or 404 NO_PASSKEY -> use OTP)
let auth = try await myBackend.startPasskeyAuth(phone)
// 2. Run the ceremony. `callbackScheme` is your app's URL scheme.
let passkey = AkedlyPasskey() // or AkedlyPasskey(ceremonyOrigin: "https://localhost:5174") for shared-cert QA
let result = await passkey.openPasskeyCeremony(token: auth.ceremonyToken, callbackScheme: "myapp")
if result.verified {
// 3. Confirm offline on YOUR backend without waiting for polling/callback — see below.
try await myBackend.completeSignIn(
resultToken: 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 {
// result.reason: "closed" (cancel) | "busy" | "invalid_url" | "start_failed"
// | "failed" | <server code>
await runOtpFallback()
}
openPasskeyCeremony(token:callbackScheme:) is async and returns an AkedlyPasskeyResult — it doesn't throw on a normal outcome, so you can always branch to OTP:
- Name
verified- Type
- Bool
- Description
trueonly on a completed, successful ceremony.
- Name
resultToken- Type
- String?
- Description
The signed proof of a verified outcome — forward it to your backend to confirm the sign-in. A verified result always carries one; a bare
verifiedwith no token is not trustworthy.
- Name
reason- Type
- String?
- Description
nilwhen verified; otherwise"closed"(user cancelled) |"busy"(a ceremony is already in flight) |"invalid_url"|"start_failed"|"no_proof"(claimed or recovered success without aresultToken) |"failed". Reconcile the transaction before falling back to OTP.
No Info.plist setup needed
ASWebAuthenticationSession receives the deep link through its callbackURLScheme
parameter — the SDK launcher needs no CFBundleURLSchemes entry in Info.plist.
Register the scheme under CFBundleURLTypes only if you launch /pk with something
other than ASWebAuthenticationSession (e.g. SFSafariViewController or the system
browser), where the OS must route the redirect back to your app.
Fall back to OTP
Every non-verified outcome resolves with result.verified == false and a result.reason — "closed" (the user cancelled), "busy", "invalid_url", "start_failed", "no_proof", "failed", or a server code. 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 flow.
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 iOS app. Use the hub verifier for token format, HMAC validation, callback behavior, and replay rules.
Without the SDK (open the page yourself)
AkedlyPasskey is a thin convenience wrapper. You can run the exact same ceremony with nothing but ASWebAuthenticationSession (or SFSafariViewController / a Custom Tab) and parse the redirect query yourself:
import AuthenticationServices
import UIKit
// ASWebAuthenticationSession must be RETAINED for its lifetime (a local is deallocated before the
// callback) and, on iOS 13+, given a presentation anchor — then started. A stored property on a
// presentation-context provider is the minimal correct shape:
final class PasskeyLauncher: NSObject, ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
func start(ceremonyToken: String) {
let url = URL(string: "https://auth.akedly.io/pk?token=\(ceremonyToken)&returnUrl=myapp://akedly-passkey")!
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "myapp") { callbackURL, _ in
guard let callbackURL else { return } // nil on user-cancel — fall back to OTP
// callbackURL = myapp://akedly-passkey?verified=true&transactionId=…
// (resultToken rides ONLY if your backend signed returnTarget into the ceremony token)
let result = AkedlyPasskeyCeremony.parseResult(from: callbackURL) // or parse the query yourself
_ = result
}
session.presentationContextProvider = self // iOS 13+ requires an anchor
session.prefersEphemeralWebBrowserSession = false // share the device passkey store
self.session = session // retain until the callback fires
session.start() // actually open the ceremony
}
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
The page reads returnUrl from the query, runs WebAuthn on the akedly.io origin, then redirects to returnUrl with the result in the query string. A query-supplied returnUrl is untrusted, so the redirect carries no resultToken: to get the proof your backend must sign a returnTarget into the ceremony token via /auth-options. Without it a successful ceremony still returns verified: false / no_proof. The AkedlyPasskeyCeremony.buildURL / .parseResult helpers are public if you want the parsing without the session wrapper.
SwiftUI Example
SwiftUI
import SwiftUI
import AkedlyShield
// Calls YOUR backend proxy; see Quick Start for the Node.js server.
let backendUrl = "https://yourapp.com"
struct OTPView: View {
let phoneNumber: String
// Supply the same 4, 5, or 6 value configured by your backend.
let otpLength: Int
@State private var transactionReqID: String?
@State private var otp = ""
@State private var loading = false
@State private var error: String?
var body: some View {
VStack(spacing: 20) {
if transactionReqID == nil {
Text("Send OTP to \(phoneNumber)")
Button(loading ? "Sending..." : "Send OTP") {
Task { await sendOTP() }
}
.disabled(loading)
} else {
TextField("Enter \(otpLength)-digit OTP", text: $otp)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
Button(loading ? "Verifying..." : "Verify") {
Task { await verifyOTP() }
}
.disabled(loading || otp.count < otpLength)
}
if let error {
Text(error).foregroundColor(.red)
}
}
.padding()
}
func sendOTP() async {
loading = true
error = nil
do {
let url = URL(string: "\(backendUrl)/auth/akedly/challenge")!
let (data, _) = try await URLSession.shared.data(from: url)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let d = json["data"] as! [String: Any]
var powSolution: [String: Any]?
if d["challengeRequired"] as? Bool == true,
let challenge = d["challenge"] as? String,
let difficulty = d["difficulty"] as? Int,
let challengeToken = d["challengeToken"] as? String {
let nonce = await solvePow(challenge: challenge, difficulty: difficulty)
powSolution = ["challengeToken": challengeToken, "nonce": nonce]
}
var turnstileToken: String?
if let ts = d["turnstile"] as? [String: Any],
ts["required"] as? Bool == true {
turnstileToken = try await AkedlyTurnstile()
.getToken(siteKey: ts["siteKey"] as! String)
}
var request = URLRequest(url: URL(string: "\(backendUrl)/auth/akedly/send")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["phoneNumber": phoneNumber]
if let solution = powSolution { body["powSolution"] = solution }
if let token = turnstileToken { body["turnstileToken"] = token }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (resData, _) = try await URLSession.shared.data(for: request)
let result = try JSONSerialization.jsonObject(with: resData) as! [String: Any]
transactionReqID = (result["data"] as? [String: Any])?["transactionReqID"] as? String
} catch {
self.error = error.localizedDescription
}
loading = false
}
func verifyOTP() async {
loading = true
error = nil
do {
var request = URLRequest(url: URL(string: "\(backendUrl)/auth/akedly/verify")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["transactionReqID": transactionReqID!, "otp": otp])
let (data, _) = try await URLSession.shared.data(for: request)
let result = try JSONSerialization.jsonObject(with: data) as! [String: Any]
if result["status"] as? String == "success" {
// Handle success
} else {
self.error = "Invalid OTP"
}
} catch {
self.error = error.localizedDescription
}
loading = false
}
}
UIKit Example
UIKit
import UIKit
import AkedlyShield
// Calls YOUR backend proxy; see Quick Start for the Node.js server.
let backendUrl = "https://yourapp.com"
class OTPViewController: UIViewController {
var phoneNumber: String = ""
private var transactionReqID: String?
func sendOTP() {
Task {
do {
let url = URL(string: "\(backendUrl)/auth/akedly/challenge")!
let (data, _) = try await URLSession.shared.data(from: url)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let d = json["data"] as! [String: Any]
var powSolution: [String: Any]?
if d["challengeRequired"] as? Bool == true,
let challenge = d["challenge"] as? String,
let difficulty = d["difficulty"] as? Int,
let challengeToken = d["challengeToken"] as? String {
let nonce = await solvePow(challenge: challenge, difficulty: difficulty)
powSolution = ["challengeToken": challengeToken, "nonce": nonce]
}
var body: [String: Any] = ["phoneNumber": phoneNumber]
if let solution = powSolution { body["powSolution"] = solution }
if let ts = d["turnstile"] as? [String: Any],
ts["required"] as? Bool == true {
let token = try await AkedlyTurnstile()
.getToken(siteKey: ts["siteKey"] as! String)
body["turnstileToken"] = token
}
var request = URLRequest(url: URL(string: "\(backendUrl)/auth/akedly/send")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (resData, _) = try await URLSession.shared.data(for: request)
let result = try JSONSerialization.jsonObject(with: resData) as! [String: Any]
transactionReqID = (result["data"] as? [String: Any])?["transactionReqID"] as? String
} catch {
print("Error: \(error)")
}
}
}
func verifyOTP(_ otp: String) {
guard let transactionReqID else { return }
Task {
var request = URLRequest(url: URL(string: "\(backendUrl)/auth/akedly/verify")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["transactionReqID": transactionReqID, "otp": otp])
let (data, _) = try await URLSession.shared.data(for: request)
let result = try JSONSerialization.jsonObject(with: data) as! [String: Any]
if result["status"] as? String == "success" {
// Handle success
}
}
}
}
