/verify/start and /verify/done below are your route names — use whatever fits the action you are protecting (/auth/login, /payments/confirm, /profile/change-phone). Only the Authentica paths are fixed.The result of a verification never travels through the browser, and nothing is ever delivered to your server. Your server learns the outcome by calling
GET /session/{sessionId}. The browser only carries the single-use handoff token in a form POST on the way out, and two non-secret identifiers on the way back.Step 1 — Create a session (your backend)
Add a route on your API (example:POST /verify/start).
Inside that route:
- Run your own checks for the action — password, balance, permissions, ownership, whatever applies.
- Generate a random
stateand store what the action is (and its parameters) against it. - Call Authentica from your backend with a stable
userRef, phonedestination,"channel": "sms", andoperationSensitivitychosen by your server (not the browser). Send anIdempotency-Keyso a timeout retry cannot text the user twice:
"sensitive" for higher-risk actions — payments, transfers, permission changes. Full field reference: Create session.
Successful envelope:
- Store
sessionIdnext tostateon the pending action. It is public and non-secret; it is how you read the outcome in step 4.
verifyUrl is a bare endpoint with no query string. handoff is a single-use token with a 120 s TTL that opens the verify page — it must never be put in a URL.
Return both to the browser:
Step 2 — Hand off to the verify page (browser)
- Call your start route and get
{ verifyUrl, handoff }. - Auto-submit a hidden form to
verifyUrl— do not navigate to it:
Step 3 — Land the user (your backend)
{RETURN} is a plain GET route in your app. After verification the browser is redirected to:
SameSite=Lax session cookies arrive as usual: no CSRF exemption, no POST route, no cookie tricks.
Step 4 — Read the result (your backend)
From that same landing route, ask Authentica what actually happened:- Look up your pending record by
state. No record, or already fulfilled? Stop. - Require
status == "verified", the echoedstateto equal your record’s, anduserRefto be the user you started for. (assertionExchangedis a legacy wire field — ignore it.) - Run your idempotent
onVerified(sessionId): perform the action and mark the record used in one transaction. - Set your own session / cookie and redirect the user onward with a
303.
GET /session/{id} is idempotent, poll-safe, and answers for 24 hours — so it also covers the user who verified and closed the tab. A periodic sweep over your pending records finishes those.
Per-language landing routes: C# · Node.js · Java · PHP / Laravel.
Step 5 — Smoke test
The
returnUrl you send on POST /session is one of the URLs registered in the portal, character-for-character.POST /session with Basic auth and an Idempotency-Key returns sessionId, verifyUrl and handoff. Repeating with the same key replays the response (Idempotent-Replayed: true) instead of sending a second SMS.The browser POSTs
hx to verifyUrl; the address bar shows the bare verify URL with no query string.Complete a real verification: the browser lands on
returnUrl?session_id=…&state=…, and GET /session/{sessionId} returns status: "verified" with your state echoed back.Reloading the landing page performs the action exactly once.
Hand-editing
session_id or state on the landing URL fulfils nothing.Replaying the same
handoff, or GETing verifyUrl with a query string, returns 400.A session left to complete with the tab closed is still resolvable later by
GET /session/{sessionId}.
