Open the widget
Open the widget in an iframe on the web or in a system browser on mobile, and handle browsers that block cookies.
Step 3: Frontend - Open Widget
Once you receive the attemptId and iframeUrl from your backend, open the Akedly widget — in an iframe on the web, or in a system browser in a mobile native app. Do not load it in a raw WebView (see Mobile native apps below).
Implementation Options
Web — iframe. Embed the widget in an iframe, in a modal/dialog (recommended) or inline in your page. It fits a 500px × 700px iframe. For passkeys, add the allow attribute — see Passkeys. For browsers that block cookies, add clipboard-write and check any sandbox attribute — see When the browser blocks cookies.
Web — full-page redirect (no iframe). Prefer not to embed? Send the whole browser tab to the iframeUrl. When the user finishes, the widget redirects back to your pipeline's Front-end Redirect URL with the same query parameters — so there's nothing to wire up beyond that one pipeline setting. A top-level page also has WebAuthn permission natively, so passkeys work with nothing to add (the allow attribute is iframe-only). Good when an iframe is awkward (CSP, small screens) or you just prefer a hosted page. See the snippet below.
Mobile native apps — a system browser (required). Open the widget in ASWebAuthenticationSession or SFSafariViewController (iOS), or Chrome Custom Tabs (Android) — never a raw WKWebView / Android WebView. See the Mobile native apps section below for why and how.
Handling Results
How the result reaches you depends on the surface:
- Web (iframe) —
AUTH_SUCCESSis an optional hint. With a web Front-end Redirect URL, the widget sends the user to that URL from inside the widget frame shortly after success and does not postAUTH_SUCCESS, so your callback page can load inside the iframe. The backend webhook, or a status lookup, is the source of truth. A user who finishes in a new tab is reported through the redirect and the backend webhook, notpostMessage— see When the browser blocks cookies. - Web (full-page redirect) — no iframe means no
postMessage; the result comes purely from the redirect to your pipeline's Front-end Redirect URL. - Mobile native (system browser) — there is no parent window, so
postMessagedoes not apply. Use the redirect to your pipeline's Front-end Redirect URL (a deep link your app intercepts).
The redirect carries status, transactionId, attemptId, timestamp, and any meta_* parameters in the query string. A successful test-pair redirect also carries isTest=true (configured per pipeline — see Pipeline Setup).
import { useState, useEffect } from 'react'
export default function AuthModal() {
const [isOpen, setIsOpen] = useState(false)
const [iframeUrl, setIframeUrl] = useState('')
const [loading, setLoading] = useState(false)
const startAuth = async (phoneNumber, email = null) => {
setLoading(true)
try {
const response = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phoneNumber, email }),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error)
}
setIframeUrl(data.iframeUrl)
setIsOpen(true)
} catch (error) {
alert('Failed to start authentication: ' + error.message)
} finally {
setLoading(false)
}
}
// Listen for postMessage from iframe
useEffect(() => {
const handleMessage = (event) => {
if (event.origin !== 'https://auth.akedly.io') return
if (event.data.type === 'AUTH_SUCCESS') {
console.log('Authentication successful!', event.data)
setIsOpen(false)
onAuthSuccess(event.data)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
const onAuthSuccess = (data) => {
// Your success logic
window.location.href = '/dashboard'
}
return (
<>
<button
onClick={() => startAuth('+201234567890', 'user@example.com')}
disabled={loading}
>
{loading ? 'Loading...' : 'Login with Phone'}
</button>
{isOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={(e) => {
if (e.target === e.currentTarget) setIsOpen(false)
}}
>
<iframe
src={iframeUrl}
// Optional: enable passkeys (see /authentication/passkeys)
allow="publickey-credentials-get *; publickey-credentials-create *; clipboard-write"
style={{
width: '500px',
height: '700px',
border: 'none',
borderRadius: '12px',
background: 'white',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
}}
/>
</div>
)}
</>
)
}
Mobile native apps — open in a system browser
Native apps MUST use a system browser, not a WebView
In a mobile native app, open the widget in the OS's system browser —
ASWebAuthenticationSession or SFSafariViewController on iOS, Chrome Custom Tabs on
Android. Do not load it in a raw WKWebView / Android WebView. Akedly is an authentication
provider: the widget runs Cloudflare Turnstile and WebAuthn (passkeys) on the akedly.io origin in
a real browser, and only a system browser gives it that browser's trust model, cookies, and
credential store. A raw WebView has none of that — passkeys can't run and the captcha is often
degraded.
Why a system browser is required
- Name
- Passkeys (WebAuthn)
- Type
- required
- Description
The widget invokes WebAuthn on the
akedly.ioorigin. That runs only in a real browser; a raw WebView can't, so passkeys silently fall back to OTP at best. A system browser gives the user the platform's native passkey UI — with no Associated Domains /apple-app-site-association, no Digital Asset Links, and no entitlements on your side, because the credential is bound to Akedly's web origin, not your app.
- Name
- Cloudflare Turnstile
- Type
- required
- Description
The mandatory captcha and Akedly's bot/fraud checks expect a real browser environment. WebViews are routinely flagged or challenged.
- Name
- Cookies & session
- Type
- required
- Description
A system browser shares the device browser's cookies and keychain, so returning-user and device-trust signals persist. A WebView is an isolated, cookie-jarred container.
- Name
- Minimal setup
- Type
- benefit
- Description
The passkey credential is bound to Akedly's web origin, not your app, so it needs no Associated Domains /
apple-app-site-association, no Digital Asset Links, and no passkey entitlements on your side. The only thing you register is your callback, and the simplest one needs no domain association at all: a custom scheme (a one-lineInfo.plistCFBundleURLTypesentry on iOS, or a manifestintent-filteron Android, both shown below). AnhttpsApp Link / Universal Link callback works too, but — like any HTTPS deep link — it requires its own OS association setup (AASA on iOS, Digital Asset Links on Android), so reach for a custom scheme unless you specifically need one.
Which API to use
| Platform | Use | Result delivery |
|---|---|---|
| iOS | ASWebAuthenticationSession (preferred) or SFSafariViewController | Custom-scheme deep link / Universal Link |
| Android | Chrome Custom Tabs (androidx.browser) | Deep link / App Link back into your app |
| React Native | expo-web-browser openAuthSessionAsync (wraps both natives) | Return URL from the resolved promise |
| Flutter | flutter_web_auth_2 (wraps both natives) | Callback URL from authenticate(...) |
| Any other framework | The framework's system-browser / web-auth API (never its WebView component) | Deep-link callback URL |
Whatever stack you're on, it's the same two OS APIs underneath — so the rule never changes: open auth.akedly.io in the framework's system-browser / auth-session API (not its embedded WebView), and read the result from your callback. See Other frameworks below for Capacitor/Ionic, Cordova, .NET MAUI, and the fallback for anything not listed.
Result handling on native
There is no parent window in a system browser, so postMessage does not apply. Instead, set your
pipeline's Frontend Callback URL (Authentication → Setup → Pipelines → your pipeline → Callback URLs) to a URL your app
intercepts — typically a custom-scheme deep link such as myapp://akedly/callback. When
verification completes, the widget redirects the browser to that URL with the usual query parameters
(status, transactionId, attemptId, timestamp, and any meta_*); the OS hands the URL back
to your app and closes the browser. A custom scheme needs no domain association; an https
Universal Link / App Link works too but requires its own OS association (AASA on iOS, Digital Asset
Links on Android).
import AuthenticationServices
import UIKit // for the presentationAnchor (UIApplication / UIWindowScene)
final class AkedlyAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
/// `iframeUrl` is the URL your backend returned from create-attempt.
/// `callbackScheme` is your app's custom scheme, e.g. "myapp".
/// Set your pipeline's Front-end Redirect URL to "<scheme>://akedly/callback".
func start(iframeUrl: URL,
callbackScheme: String,
onResult: @escaping (Result<URL, Error>) -> Void) {
let session = ASWebAuthenticationSession(
url: iframeUrl,
callbackURLScheme: callbackScheme
) { callbackURL, error in
if let callbackURL { onResult(.success(callbackURL)) }
else if let error { onResult(.failure(error)) }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // share cookies + passkeys
self.session = session
session.start()
}
func presentationAnchor(for s: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
}
}
// Usage — retain the helper for the session's lifetime. A temporary would be
// deallocated when this function returns, cancelling the flow; store it (e.g. a
// `var akedlyAuth: AkedlyAuth?` property on your view controller):
self.akedlyAuth = AkedlyAuth()
self.akedlyAuth?.start(iframeUrl: iframeUrl, callbackScheme: "myapp") { result in
switch result {
case .success(let url):
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
let status = items?.first { $0.name == "status" }?.value // "success" / "failed"
// read transactionId, attemptId, timestamp, meta_* the same way
case .failure(let error):
print("Auth cancelled or failed: \(error)")
}
}
Migrating from a WebView (WKWebView / Android WebView)?
Already shipping the widget inside a WebView? Move it to a system browser. Your backend
create-attempt call is unchanged — same iframeUrl. Swap the WebView for the native API above,
point your pipeline's Front-end Redirect URL at a deep link, and read the result from that callback
instead of intercepting WebView navigation. Passkeys then light up automatically and OTP keeps
working — no other changes.
If you do load the widget in an Android WebView anyway, DOM storage must be on: settings.domStorageEnabled = true in Kotlin, setDomStorageEnabled(true) in Java, or the domStorageEnabled prop in react-native-webview. Android turns it off by default, and the widget then stops on the cookie screen described in When the browser blocks cookies. The real fix is the system browser.
When the browser blocks cookies
The widget keeps a stable device ID in the browser's website storage (localStorage). Some browsers block it: iPhone Safari with "Block All Cookies" on, a browser that blocks cookies for the site, an Android WebView without DOM storage, or a browser that blocks storage only inside embedded frames. Before it makes any verification request, the widget checks that it can write and read storage. If it cannot, the user sees a "Turn on cookies to continue" screen instead of an error.
While storage is blocked, the widget:
- Makes no verification requests and never continues without storage.
- Shows step-by-step instructions, with small drawings, for the detected browser. The user can pick another browser from an "other browsers" list if detection is wrong.
- Uses English or Arabic (right to left) from the device language, and follows the device's light or dark setting. It uses Akedly's default look, not your widget branding.
- Rechecks when the user taps "I’ve turned it on — try again" and when they return to the page. When storage works, the same attempt continues without a page reload. The attempt keeps its 5-minute lifetime. After that, your backend creates a new attempt.
- Inside an iframe, offers "Open in a new tab". This opens the same widget URL as a top-level page, where the browser is more likely to allow storage. If the browser blocks the popup, the widget says so and offers Copy link.
- In in-app browsers, iPhone browsers other than Safari, and WebViews, offers "Copy link" so the user can paste the address into Safari or Chrome. If copying fails, the address appears in a read-only field.
What you need to do
iframes. Add clipboard-write to the allow attribute so Copy link works. Without it, the widget falls back to the read-only address field.
<iframe
src="https://auth.akedly.io/auth?attemptId=..."
allow="publickey-credentials-get *; publickey-credentials-create *; clipboard-write"
></iframe>
If you add a sandbox attribute, it must include all of these tokens:
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
Without allow-same-origin, the frame renders blank, because the widget's own files refuse to load for a sandboxed origin. Without allow-popups and allow-popups-to-escape-sandbox, "Open in a new tab" cannot open a working page.
WebViews. A raw WebView is still not supported — see Mobile native apps. If you load it in an Android WebView anyway, DOM storage must be on — see the note at the end of that section.
A new-tab finish does not send AUTH_SUCCESS to your iframe
When the user finishes in the new tab, the success happens in that tab, not in the frame your page listens to. Your iframe does not receive AUTH_SUCCESS and stays on the cookie screen. The result still reaches you through the backend webhook, which fires either way. The new tab is also sent to the pipeline's Front-end Redirect URL, but only when one is set. Treat the webhook as the source of truth, and move your page on from your own backend state or the redirect instead of relying only on postMessage.
