Step-up authentication
Proving a second factor again before a privileged write: which actions need it, the fifteen-minute window, the two factors that satisfy it, and the one that does not.
Step-up authentication is the platform asking you to prove a second factor again, minutes before you do something that would be expensive to undo. Holding a valid session gets you into the console. It does not, on its own, let you create a user, edit a role, or change your organisation's settings.
The reasoning is narrow and worth stating, because it decides how you should think about the feature. A session is a long-lived thing on a laptop that may be in a coffee shop. A privileged write is a short, deliberate act. Requiring the second factor at the moment of the act rather than at the start of the day means a stolen session is worth far less than the account it came from — the attacker gets the console, and does not get to grant themselves the admin role with it.
What this used to be, and why we are telling you
We publish that because a control that silently did nothing is exactly the kind of thing a customer is entitled to hear about from the vendor rather than from an assessor. It also explains the next section, which is otherwise a surprising piece of behaviour.
Which actions require it
Three permissions require a fresh second factor unconditionally, and three more require one unless you are on a device and a network the platform recognises as trusted.
| Permission | When step-up is required | The requests it covers |
|---|---|---|
users.write | Always | Creating a user in an organisation. Note that clearing somebody's MFA also needs users.write and does not go through this check. |
roles.write | Always | Creating a role, changing the permissions on one, and deleting one. |
organizations.write | Always | Running a lifecycle action against the organisation. Renaming the organisation is gated on the same permission but deliberately does not go through this check. |
api_keys.write | On a low-trust device, an untrusted network, or during an elevated incident | Minting an API key. |
members.write | The same three conditions | Adding or changing an organisation member. |
sso.write | The same three conditions | Registering or changing a single sign-on provider. |
Step-up is checked in addition to the ordinary permission check and, for several of these, in addition to a role-name check. Adding a member, editing a role and configuring single sign-on all also require your role to be literally named admin or delegated_admin — see Delegated administration. Three different refusals with three different meanings can come out of one button, and knowing which you got usually answers the question immediately.
| Response | Meaning | What fixes it |
|---|---|---|
403 {"error":"insufficient permissions"} | The role does not hold the permission. | An administrator grants it. See The permission model. |
403 action requires elevated delegated role | The role holds the permission but is not named admin or delegated_admin. | Rename the role, or use one that is. This is a comparison of the name, not of the contents. |
401 with "code":"step_up_required" | Everything is granted; you have not proved a second factor recently enough. | Complete a step-up and retry. Read on. |
HTTP/1.1 401 Unauthorized
{
"error": "step-up authentication required",
"code": "step_up_required",
"message": "This action needs a second factor. Re-authenticate with MFA, then try again.",
"action": "re-authenticate with MFA and retry the request"
}The code field is load-bearing and exists because of a real failure. Without it, a client seeing a bare 401 runs its generic refresh-then-retry: the refresh succeeds, because the session is perfectly valid, the retry hits the same 401, and the role the administrator thought they had created was silently not created. If you are writing a client against this API, branch on code and never on the status alone.
How to step up
Two endpoints, both on your own session and neither carrying an organisation in the path. They are deliberately outside the permission system: a gate that required the thing it grants would be unsatisfiable by the person being refused.
GET /api/v1/auth/step-up what would satisfy it, and whether you already have
POST /api/v1/auth/step-up prove it{
"stepped_up": false,
"stepped_up_until": null,
"factors": ["totp", "recovery_code"],
"enrollment_required": false,
"enrollment": null,
"recovery_codes_remaining": 7,
"window_seconds": 900
}recovery_code appears in factors only if you have unused codes left. stepped_up exists so a console can skip a second prompt inside the window rather than asking you twice for the same fifteen minutes.
{"totp_code": "123456"}
{"recovery_code": "ABCDEFGH-JKMNPQRS"}They are alternatives, and deliberately not fallbacks for one another: if a mistyped authenticator code fell through to being checked as a recovery code, a typo would eventually burn one of the ten pieces of paper you are keeping for an emergency.
A successful step-up returns a re-signed access token and nothing else — your refresh token and your session row are untouched. That is on purpose: issuing a whole new pair would tear down a console shell in the middle of the form you were filling in.
{
"access_token": "<use this from now on>",
"token_type": "Bearer",
"expires_at": "2026-09-03T10:35:00Z",
"step_up_at": "2026-09-03T10:20:00Z",
"step_up_expires_at": "2026-09-03T10:35:00Z",
"step_up_method": "totp"
}The window, and what quietly drops it
One proven factor authorises privileged writes for fifteen minutes. It is a property of the access token rather than of the session or of one action, so inside that window you can perform any number of gated actions without being asked again — and outside it, the answer to I stepped up an hour ago is no.
Fifteen minutes is also exactly how long an access token lives, which is not a coincidence. The stamp cannot outlive the credential carrying it.
What satisfies it — and the gap you should know about
Exactly three things, and the list is short enough to give in full.
- `totp` — a six-digit code from your authenticator
- The ordinary case. The code is spent against the same replay guard the sign-in path uses, so a code you have just used to sign in cannot also be used to step up.
- `recovery_code` — one of your printed codes
- Single use, and consumed atomically so two simultaneous attempts cannot both succeed. This is the one route where a recovery code genuinely works today. See Recovery codes.
- `totp_enrollment` — a first factor, enrolled on the spot
- If the account has no factor at all, the step-up endpoint answers
409with a secret and a QR code rather than refusing, and completing the enrolment both enables MFA and satisfies the step-up. It also returns ten recovery codes, once.
One credential type is refused outright: an API key cannot complete a step-up, and any request presenting one is answered 403 this credential cannot complete step-up authentication. An API key has no second factor by definition, so allowing it would make the whole gate optional for anybody holding one. The practical consequence is that a script cannot perform the six gated actions; those need a person. See API tokens.
Three attempts per code, and why not one
The endpoint allows three verification attempts per thirty-second code window, and the budget is spent before anything is checked, so a malformed guess costs an attempt too.
Not one, because a person who mistypes six digits must not be told to wait for the next code — a flow that punishes a typo is a flow people work around. Not unbounded either: three per step is six per minute, which turns a million-code space into something like two thousand hours of continuous guessing against a single account, and every attempt leaves an audit row.
Over budget answers 429 with "code":"step_up_too_many_attempts". Wait for the next code and try again; there is no lockout and nothing to unlock.
| Status | `code` | Meaning |
|---|---|---|
| 200 | — | Elevated. Use the returned access token from here on. |
| 400 | step_up_invalid_factor | Wrong code, an already-used recovery code, or a body with neither field set. |
| 403 | — | this credential cannot complete step-up authentication — you presented an API key. |
| 401 | — | invalid session. The token is not valid, or the account is inactive. |
| 409 | step_up_enrollment_required | No factor enrolled. The body carries a secret and a QR code; enrol and the step-up completes. |
| 429 | step_up_too_many_attempts | Three attempts already spent on this code window. |
| 500 | — | step-up authentication failed. Ours; worth reporting. |
What it records
Every outcome writes an audit row, which is what makes the control reviewable rather than merely present:
auth.step_up_verified a factor was accepted
auth.step_up_enrolled a first factor was enrolled to satisfy a step-up
auth.step_up_failed a wrong one-time code
auth.step_up_recovery_used a recovery code was spent
auth.step_up_recovery_failed a wrong or already-used recovery code
auth.step_up_code_replayed a code already spent elsewhere was presented
auth.step_up_throttled the three-attempt budget was exhausted
auth.login_step_up_required a sign-in was answered with a challengeauth.step_up_code_replayed and a run of auth.step_up_throttled are the two worth an alert rule of your own. The first means a code was captured and reused somewhere; the second means somebody is guessing.
What to do next
- Check
GET /api/v1/auth/step-upon your own account. If it reportsenrollment_required, you have no second factor and every gated action will ask you to enrol one first. - Read Recovery codes and confirm your administrators know where theirs are — a step-up at an awkward moment is exactly when one gets used.
- Read Multi-factor authentication factors for the passkey gap, which interacts with everything above.
- If you are writing a client, branch on
coderather than on the401, and handlestep_up_requiredappearing mid-sequence after a token refresh.