Akedly

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:

  1. File > Add Package Dependencies
  2. Enter: https://github.com/Akedly-Org/akedly-shield-swift
  3. Select version 1.1.0 or 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

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

SWIFT
// 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.

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

SWIFT
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

SWIFT
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

    true only 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 verified with no token is not trustworthy.

  • Name
    reason
    Type
    String?
    Description

    nil when verified; otherwise "closed" (user cancelled) | "busy" (a ceremony is already in flight) | "invalid_url" | "start_failed" | "no_proof" (claimed or recovered success without a resultToken) | "failed". Reconcile the transaction before falling back to OTP.

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

SWIFT
OTPView.swift
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

SWIFT
OTPViewController.swift
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
            }
        }
    }
}

Was this page helpful?