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.