Skip to main content

Two-factor security

Users secure sensitive actions with a TOTP authenticator app. These endpoints manage enrolment and mint the grants that step-up verification consumes.

All of them require a signed-in caller and act on that caller only.

Status​

GET /api/v1/Security/totp​

{
"data": {
"enrolled": true,
"enrolledAt": "2026-08-20T09:00:00.0000000Z",
"lockedUntil": null
}
}

This is the only endpoint that reports enrolment state. The code-submitting endpoints deliberately return one uniform failure, so they cannot be used to probe an account.

lockedUntil is set after repeated wrong codes. While it is in the future, code submission fails.

Enrol​

POST /api/v1/Security/totp/enrol​

{
"data": {
"url": "otpauth://totp/ObsidianPay:ana@example.com?secret=...&issuer=ObsidianPay",
"barcode": "iVBORw0KGgoAAAANSUhEUg..."
}
}

url is the provisioning URI and barcode is the same secret rendered as a PNG for scanning. Both carry the shared secret. Show them to the user, then discard them. Never log, cache or persist either one.

This call does not enable two-factor. The key stays pending until it is confirmed.

POST /api/v1/Security/totp/enrol/confirm​

{ "code": "123456" }

Proves the authenticator holds the key, and completes enrolment. Until this succeeds, the user counts as not enrolled.

Calling enrol while already enrolled fails with core::gatekeeper::totp::already_enrolled.

Verify a code​

POST /api/v1/Security/totp/verify​

{ "code": "123456" }
{
"data": {
"token": "eyJhbGciOi...",
"expiresAt": "2026-09-16T14:35:00.0000000Z"
}
}

Exchanges a current code for a short-lived grant. Send that grant in the X-Step-Up header on the sensitive request that follows. One grant covers several requests until it expires.

Exclude this endpoint from any automatic step-up retry logic in your client. It mints grants, so it can never itself be challenged.

Remove two-factor​

DELETE /api/v1/Security/totp​

{ "code": "123456" }

Requires a current code. Removing the account's strongest factor must not be possible from a stolen session alone.

Failure handling​

Every rejected code returns 400 with core::gatekeeper::code::invalid, whether the code was wrong, the user is not enrolled, or the account is locked out. This is deliberate: a specific message would turn the endpoint into an oracle for account state.

Show one message for all of them, for example "Invalid or expired code", and let the user retry. Read GET /api/v1/Security/totp when you need to know whether they are enrolled or locked out.

CodeStatusMeaning
core::gatekeeper::code::invalid400Code rejected. Reason is deliberately not disclosed
core::gatekeeper::totp::already_enrolled409Enrolment attempted while already enrolled
core::gatekeeper::enrol::fail500Enrolment state could not be written. Previous state intact, retry is safe
core::gatekeeper::grant::invalid503The secrets service is unavailable. Retry later