1
Get your credentials
Create the app at
https://authentica-merge.t2.sa/integrate and copy your client_id, secret and Return URL. Walkthrough: Get your credentials.2
Fill in the four values
Replace the placeholders at the top of the prompt. Everything below them is already complete.
3
Paste it into your assistant
Open it with your repository as context, so it can actually see your existing OTP code.
4
Review the diff
Treat the output as a pull request, not as truth. The review prompt below is the second half of the job.
Put your
secret in the prompt only if you trust the assistant with it. Otherwise leave YOUR_SECRET as-is and tell it to read the value from an environment variable — the prompt already instructs it to do that.The migration prompt
You are migrating this project from its own OTP implementation to Authentica Verify, a
hosted verification service that REPLACES a self-built OTP stack. Work across the entire
repository. Do not stop at the first match.
=====================================================================
MY VALUES (use these literally; do not invent placeholders)
=====================================================================
AUTHENTICA_BASE_URL = https://authentica-merge-integration.t2.sa
AUTHENTICA_CLIENT_ID = <PASTE YOUR CLIENT ID>
AUTHENTICA_CLIENT_SECRET = YOUR_SECRET (leave this token as-is to keep the secret out of
this prompt — the rules below tell you to read it from the
environment variable AUTHENTICA_CLIENT_SECRET)
AUTHENTICA_RETURN_URL = <PASTE THE RETURN URL REGISTERED IN THE PORTAL>
=====================================================================
WHAT AUTHENTICA VERIFY IS
=====================================================================
A hosted verification service that REPLACES the OTP stack a team would otherwise build:
code generation, code storage, expiry, retry and rate limiting, resend logic, the SMS
provider contract, and the code-entry screen. Authentica hosts the verification page and
owns HOW the user proves themselves — an SMS one-time code today, or a passkey when the
user has one enrolled (operationSensitivity "sensitive" forces the code). Do not describe
it in generated comments or docs as "an OTP provider": it is the replacement for OTP, and
the method can change without this project changing.
It is NOT a login provider and NOT a passwordless product. It does not manage users,
sessions, passwords or roles. Those stay entirely in this project. Authentica answers
exactly one question: "did this person complete verification just now?"
Because of that, it is not limited to sign-in. A verification session is created for AN
ACTION, and the application decides what that action is: login second factor, signup,
password reset, payment or checkout confirmation, adding a card/IBAN/beneficiary,
approving a transfer, changing a phone or email, admin and back-office operations, bulk
export, account closure, contract or e-sign confirmation, delivery confirmation.
=====================================================================
THE SHAPE OF THIS INTEGRATION — READ THIS BEFORE WRITING CODE
=====================================================================
This is the OAuth authorization-code shape. The browser carries the USER, never the
RESULT. Three facts follow from that, and most mistakes come from missing one of them:
1. The browser is handed an opaque reference, not an outcome.
2. The outcome is read back over an authenticated server-to-server call.
3. Nothing is ever delivered to your server. There is NO webhook, NO callback POST,
and NO server-to-server notification. If you find yourself writing a receiver
endpoint for Authentica to call, stop — that endpoint does not exist.
=====================================================================
THE ONLY TWO API CALLS
=====================================================================
Both are server-to-server. Both use HTTP Basic auth: base64("client_id:secret").
--- 1) CREATE SESSION -------------------------------------------------
POST {AUTHENTICA_BASE_URL}/api/V1/Verify/session
Content-Type: application/json
Idempotency-Key: <unique per logical attempt> (optional, strongly recommended)
Request body:
userRef string REQUIRED stable id of this user in MY system. The SAME
value every time, for every action. Not random.
destination string REQUIRED the phone number, e.g. "0501234567". The hosted
page does NOT ask the user for it.
channel string REQUIRED "sms" — any other value returns invalid_channel
state string REQUIRED random unguessable value I generate and store
returnUrl string REQUIRED must equal AUTHENTICA_RETURN_URL exactly
operationSensitivity string OPTIONAL "normal" (default) or "sensitive"
platform string OPTIONAL "web" (default) or "mobile"
nonce string OPTIONAL
operationSensitivity is chosen by the SERVER per action, never sent from the browser.
Use "sensitive" for money movement, permission changes and irreversible operations;
it forces SMS OTP even when the user has a passkey. Use "normal" elsewhere.
platform is also chosen by the SERVER. "web" requires an http/https returnUrl.
"mobile" additionally allows a registered custom-scheme deep link (myapp://...) so the
OS can route the return into a native app. The return is identical either way.
Success response (note the envelope — real data is under "result"):
{
"isSuccess": true,
"statusCode": 200,
"result": {
"sessionId": "vs_9f3c8a...",
"verifyUrl": "https://otp.t2.sa/verify",
"handoff": "9f3c...",
"handoffField": "hx",
"binding": "form_post",
"expiresInSeconds": 120,
"renderType": "redirect"
}
}
sessionId PUBLIC, non-secret. Store it against the pending action — it is the ONLY way
to read the outcome later. Safe to log.
verifyUrl BARE endpoint with NO query string. Use it exactly as returned.
handoff Single-use capability token, TTL expiresInSeconds (120s). NEVER log it,
NEVER store it, NEVER put it in a URL.
handoffField The form field name to submit handoff under ("hx"). Read it from the
response rather than hard-coding it.
--- 2) READ THE OUTCOME -----------------------------------------------
GET {AUTHENTICA_BASE_URL}/api/V1/Verify/session/{sessionId}
Success response:
{
"isSuccess": true,
"statusCode": 200,
"result": {
"sessionId": "vs_9f3c8a...",
"status": "verified",
"userRef": "user-123",
"reason": null,
"method": "otp",
"state": "the state I sent",
"completedAt": "2026-08-09T10:31:03.114Z"
}
}
status is one of: pending | verified | failed | expired
userRef is present only when status is "verified".
reason is present only when status is "failed", and is one of:
otp_locked too many wrong codes
otp_expired all codes expired and the resend limit is spent
device_changed the browser stopped matching the one that started the flow
session_binding_failed a call arrived from a different browser than opened the flow
Treat an unrecognised reason as a generic failure; branch on status, not on reason.
Idempotent, read-only, safe to poll. Results are retained for 24 hours.
A response may contain a legacy field "assertionExchanged" — IGNORE it completely and
never branch on it.
=====================================================================
HANDING THE BROWSER OVER — THIS IS NOT A REDIRECT
=====================================================================
Do NOT do: window.location.href = verifyUrl
A GET to verifyUrl with any query string is rejected with 400.
The browser opens the hosted page by POSTing the hand-off token to the bare verifyUrl,
using an auto-submitting hidden form:
<form id="a" method="POST" action="{verifyUrl}">
<input type="hidden" name="{handoffField}" value="{handoff}">
</form>
<script>document.getElementById('a').submit();</script>
The hosted page is always a full top-level page. It sends frame-ancestors 'none', so it
CANNOT be embedded in an iframe. There is no inline widget and no postMessage result.
Mint the session at the moment the user is ready to verify — handoff dies in 120 seconds.
=====================================================================
THE FLOW TO IMPLEMENT
=====================================================================
1. User triggers an action in my app and hits MY route.
2. MY SERVER runs my own checks (password, balance, permissions, ownership).
3. MY SERVER generates `state` and stores {state -> userRef, action kind, action params,
status: pending} in my database or cache with a TTL.
4. MY SERVER calls POST /session, and stores the returned `sessionId` on that same
pending record, next to `state`.
5. MY SERVER returns ONLY { verifyUrl, handoff } to the browser. Never the secret,
never the sessionId's meaning, never the action parameters.
6. The BROWSER form-POSTs handoff to verifyUrl (see above) and leaves.
7. The user completes verification on Authentica's hosted page.
8. Authentica sends the browser back to AUTHENTICA_RETURN_URL with an ordinary top-level
GET carrying ?session_id=...&state=... (identifiers only — they prove nothing)
A FAILED verification comes back the SAME WAY with the SAME query. Arrival is not
success. Only step 11 can tell them apart.
9. My landing route forwards {session_id, state} to MY server.
10. MY SERVER looks up the pending record by state. Unknown/expired/already-used -> reject.
11. MY SERVER calls GET /session/{session_id}.
12. If status is "verified" AND result.userRef equals the stored userRef AND result.state
equals the state I looked up, MY SERVER performs the action, marks the record used in
the same transaction, and returns where the frontend should navigate next.
13. Otherwise reject and leave the action undone. If status is "failed", close the pending
record and show result.reason to the user — do not leave it hanging as if still open.
=====================================================================
THE USER WHO NEVER COMES BACK — DO NOT SKIP THIS
=====================================================================
The user can verify successfully and then close the tab, lose signal, or have the
navigation blocked. Step 8 never happens. The verification DID succeed, and the only
record of it is on Authentica's side.
Implement a reconciliation sweep: periodically take pending records older than a few
minutes, call GET /session/{sessionId} for each, and settle any that came back
"verified" (idempotently, through the same guard as step 12). Without this, a real
percentage of successful verifications are silently lost.
=====================================================================
NON-NEGOTIABLE RULES
=====================================================================
- /session and /session/{id} are called ONLY from server-side code. Never from browser
JS, a mobile app, or any client bundle. Authentica also accepts these calls only from
allowlisted server IPs, so a browser call cannot work even if attempted.
- The client_id and secret NEVER reach the client. Read them from environment variables.
Never commit them. Never return them in an API response.
- Never trust the browser for: operationSensitivity, platform, channel, userRef, the
action type, the action parameters, or whether verification succeeded. All of these
live server-side keyed by state.
- Never treat arrival at the return URL as proof of anything. session_id and state are
identifiers, not evidence. Perform the action only AFTER GET /session/{id} returns
status "verified".
- Do not build a webhook receiver, a callback POST route, or a signature verifier.
Authentica does not call you.
- Make it idempotent: mark the pending record used atomically so a refresh, a back
button or a double submit cannot run the action twice.
- Validate that the returned userRef matches the user the pending action belongs to.
- One registered return URL serves the whole app. Do not add one per action; carry the
"which action" information in your own record keyed by state.
- Send an Idempotency-Key on POST /session. It is the call that costs money in SMS, and
the one your HTTP client will retry on a timeout.
=====================================================================
ERROR CODES TO HANDLE
=====================================================================
When isSuccess is false, read errorCode:
unauthorized bad client_id/secret, or a malformed Basic header
missing_parameters userRef, state or returnUrl missing
destination_required destination missing or not a usable phone number
invalid_return_url returnUrl not registered, or not an exact whole-string
match (trailing slash, scheme, host, port all matter)
invalid_channel channel is not "sms"
invalid_operation_sensitivity not "normal" or "sensitive"
idempotency_key_too_long Idempotency-Key over 255 characters
idempotency_key_reused same key, different payload (HTTP 422)
idempotency_request_in_flight same key while the original is still running (HTTP 409)
A missing Basic header returns HTTP 401 with an empty body.
=====================================================================
WHAT I WANT YOU TO DO
=====================================================================
A. AUDIT FIRST. Search the whole repository and list every place that implements or
touches OTP before changing anything. Look for, at minimum:
- code that generates codes (random digits, crypto.randomInt, rand(100000,999999))
- tables, models, migrations, Redis/cache keys storing codes, attempts or expiry
- SMS/provider SDK calls and HTTP calls to any SMS gateway
- "verify code", "resend", "cooldown", "attempts left", "expired" logic
- OTP entry screens, code-box components, countdown timers
- config, env vars and secrets belonging to the old OTP provider
- tests, seeders and fixtures that depend on any of the above
Present this inventory as a table with file paths and what each item does. Ask me to
confirm before you edit.
B. THEN MIGRATE each item to Authentica, following the flow and rules above:
- Add an Authentica client/service in the language and style already used here.
Match the existing HTTP client, config, DI, error handling and logging patterns.
Do not introduce a new framework or a new HTTP library.
- Add the pending-verification store (table/migration/model or cache) with a TTL,
holding at least: state, sessionId, userRef, action kind, action params, status.
- Replace every OTP send with a /session call plus the form-POST hand-off.
- Replace every OTP check with the landing route + GET /session/{id} + perform-the-
action path.
- Add the reconciliation sweep for sessions whose browser never returned.
- Delete the now-dead OTP code rather than leaving it unreferenced.
- Remove the old provider's config and env vars, and add the Authentica ones.
- Update or add tests, mocking the two Authentica endpoints.
C. COVER EVERY CASE, not just login. If this project verifies anything else — payments,
profile changes, approvals — migrate those too, giving each one the right
operationSensitivity.
D. REPORT at the end:
- the file-by-file diff summary
- every env var I must set
- the exact Return URL I must register in the Authentica portal
- anything you could not migrate and why
- remaining manual steps
Ask me before deleting anything you are unsure about. Do not invent endpoints, fields or
response shapes beyond the ones specified above — if something is missing, say so instead
of guessing.
The review prompt
Run this in the same session after the migration, before you merge anything. It makes the assistant check its own output against the rules it was given.Review the Authentica migration you just made, as a hostile reviewer. For each point,
quote the actual file and line rather than saying it looks fine.
1. Does any client_id or secret appear in browser-reachable code, a client bundle, a
mobile app, a committed file, or an API response?
2. Is /session or /session/{id} called from anywhere other than server-side code?
3. Is verifyUrl used exactly as returned — bare, with nothing appended? Is the hand-off
done with an auto-submitting form POST of handoff under handoffField, and NOT with
window.location.href / router navigation / an iframe?
4. Is handoff kept out of every URL, log line and persistent store?
5. Is state generated server-side, unguessable, stored with a TTL, and required to match
on completion? Is an unknown, expired or already-used state rejected?
6. Is sessionId stored on the pending record at creation time, so the outcome can be read
even if the browser never returns?
7. Is the action and its parameters read from the server-side record keyed by state,
never from the query string, request body, localStorage or a cookie?
8. Is the action performed strictly after GET /session/{id} returns status "verified" —
and never merely because the browser arrived at the return URL with a session_id?
9. Are BOTH the returned userRef and state compared against the stored record?
10. Is there a reconciliation sweep for verified sessions whose browser never came back?
11. Can a refresh, a back button, or a double submit run the action twice? Show me the
atomic guard.
12. Is userRef stable for the same user across every action and every session?
13. Do operationSensitivity and platform come from the server, and is "sensitive" used for
money movement, permission changes and irreversible operations?
14. Is an Idempotency-Key sent on POST /session, unique per logical attempt and reused
only for a retry of that same attempt?
15. Is every errorCode handled: unauthorized, missing_parameters, destination_required,
invalid_return_url, invalid_channel, invalid_operation_sensitivity,
idempotency_key_too_long, idempotency_key_reused, idempotency_request_in_flight —
plus a bare HTTP 401?
16. Does the landing route resolve on a hard page load, and does arriving with no
session_id do nothing instead of erroring?
17. Is there any leftover webhook receiver, callback POST route, signature verifier, or
code referencing an "assertion" or an "introspect" endpoint? None of those exist —
delete them.
18. Is any old OTP code still present — generators, code tables, SMS provider calls,
cooldown logic, code-entry screens, stale env vars or dead tests?
List every problem with its file and line, then fix them.
What the assistant still cannot do for you
| Task | Why it is yours |
|---|---|
Create the app and get client_id / secret | Only you can sign in to the portal at https://authentica-merge.t2.sa/integrate |
| Register the Return URL | Must match your deployed landing page exactly, per environment |
| Get your server IPs allowlisted | Send Authentica the outbound IPs your backend calls from |
| Set the secret in your environment | It must never be committed, so it cannot come from generated code |
| Style the verify page | Done in Page Studio, not in your codebase |
If the output looks wrong
The prompt is self-contained, but an assistant working without repository context will guess. Check the generated code against the source of truth here:- Create session and Session status — exact fields and responses
- Backend overview — hand-written samples in C#, Node.js, Java and Laravel
- Frontend overview — what the browser is and is not allowed to do
- Routing actions — carrying “which action” without putting it in the URL
- Integration checklist — tick this off before going live
- Errors — what each
errorCodemeans
If your assistant produces code that calls
POST /introspect, reads an assertion from the
return URL, or registers a redirectUri, it has guessed a shape this API does not have.
None of those exist here — delete them and follow the flow above.
