Skip to main content

Step-up verification

Sensitive actions require a fresh code from the user's authenticator app, on top of a valid access token. The pattern is always the same: the request is refused with 403, you obtain a short-lived grant, then you repeat the request with that grant attached.

The grant​

X-Step-Up: <grant token>

A grant is minted by POST /api/v1/Security/totp/verify and lasts a few minutes. It is bound to one user. While you hold one, attach it to every request: it is ignored where it is not needed and saves a round trip where it is.

Keep grants in memory only. They are bearer credentials.

The flow​

POST /api/v1/Transaction/p2p            (no grant)
-> 403 core::gatekeeper::required

POST /api/v1/Security/totp/verify { "code": "123456" }
-> 200 { token, expiresAt }

POST /api/v1/Transaction/p2p X-Step-Up: <token>
-> 200

Retry the original request once. A second 403 is a server-side problem, not a reason to prompt again.

Which actions are gated​

ActionEndpointWhen
Send moneyPOST /api/v1/Transaction/p2pAlways
Withdraw to a bankPOST /api/v1/Transaction/withdrawAlways
Resolve a disputePOST /api/v1/Transaction/dispute/resolveAlways
Resolve a dispute casePOST /api/v1/Dispute/{caseId}/resolveAlways
Unfreeze a cardPOST /api/v1/cards/{cardId}/unfreezeAlways
Raise or remove a card limitPUT /api/v1/cards/{cardId}/limitsOnly when the change loosens a cap

Freezing a card and lowering a limit are never gated. A user who thinks they have been compromised must be able to clamp down instantly.

Users without an authenticator app​

By default, a user who has not enrolled passes through gated endpoints. There is no factor to ask them for, and they should not be locked out of core features.

One exception: loosening a card spend cap. That is a privilege increase, and a user who is refused loses nothing because the card keeps working at its current limits. For that action only, an unenrolled user is refused with a distinct code.

CodeStatusMeaningWhat to do
core::gatekeeper::required403Enrolled, no valid grantAsk for a code, then retry
core::gatekeeper::enrol::required403Not enrolled, and this action requires itSend the user to enrolment, then retry
core::gatekeeper::grant::invalid503Verification is temporarily unavailableRetry later. Do not prompt again

Never show a code prompt for core::gatekeeper::enrol::required. The user has no code to enter. Route them to enrolment first.

Client pattern​

Handle this once, in your HTTP layer, rather than in each screen.

async function call(path, options, retried = false) {
const res = await fetch(path, withGrantHeader(options));
if (res.status !== 403 || retried) return res;

const code = errorCode(await res.clone().json());
if (code !== 'core::gatekeeper::required') return res; // enrol::required is not a code prompt

const grant = await promptForCode(); // your UI, returns null if cancelled
if (!grant) return res;

storeGrant(grant);
return call(path, options, true); // retry exactly once
}

Exclude POST /api/v1/Security/totp/verify from this logic. It mints grants, so it can never be challenged, and including it risks a loop.