Skip to content

Multi-factor authentication

Multi-factor authentication asks for a second proof of identity after the account password. TEBHOS accepts a TOTP authenticator app and WebAuthn credentials such as passkeys. A one-use backup code works in place of a TOTP code, and sudo mode reuses these factors to protect security-sensitive operations across the API.

All routes on this page are user-only. They require a user session token (Authorization: Bearer <token>). A valid Bot <token> credential is refused with 403 ACCESS_DENIED.

Completing a login with a second factor is described on HTTP authentication. SMS codes are covered on Phone verification and are not usable on TEBHOS.

Authenticator types

The user object reports the account's second factors in authenticator_types (see authenticator types) and sets mfa_enabled when the list is not empty.

Value Name Added when Removed when
0 TOTP Enable TOTP succeeds Disable TOTP succeeds
2 WebAuthn The first WebAuthn credential is registered The last WebAuthn credential is deleted

Password login asks for a second factor whenever the account has TOTP enabled or at least one registered WebAuthn credential. A registered passkey is therefore always a second factor. There is no separate switch.

Passkeys are missing from authenticator_types

TEBHOS currently records a passkey under a different internal value than the one the user object reports, so authenticator_types never contains 2 and mfa_enabled stays false for an account whose only second factor is passkeys. Login and sudo mode are not affected. To find out whether an account has passkeys, read webauthn from Get sudo MFA methods or list the credentials.

Not supported on TEBHOS

There is no WebAuthn two-factor toggle (PUT /users/@me/mfa/webauthn/two-factor), no emailed backup codes challenge (/users/@me/mfa/backup-codes/challenge*), and no WebAuthn assertion inside the sudo body (mfa_method, webauthn_response, webauthn_challenge). Passkeys prove sudo mode through a sudo proof instead.

TOTP parameters

TEBHOS validates a TOTP code as a six-digit HMAC-SHA-1 one-time password over a 30-second time step, and accepts one time step of clock skew on either side.

TEBHOS generates the secret. Set up TOTP returns a fresh 160-bit Base32 secret and an otpauth:// URL for a QR code, and stores the secret encrypted until Enable TOTP confirms it with a code. Any secret the client sends is ignored.

A TOTP code can pass twice

TEBHOS does not remember accepted codes. A code stays valid for the whole skew window, so the same code can be accepted again within that window.

Backup code contract

An account holds at most one set of 10 backup codes. Each code is two groups of four characters from the lowercase Latin alphabet and the decimal digits, separated by a hyphen, for example a3f2-9kd7. TEBHOS stores the codes so that the set can be shown again later together with which codes are already used.

  • Enabling TOTP generates a new set and returns it. Any previous set is replaced.
  • Regenerating replaces the whole set with 10 new codes, so every saved code stops working.
  • Disabling TOTP deletes every backup code, even when the account keeps passkeys.
  • Consuming a code is irreversible.

A backup code is accepted in the mfa_code field of the sudo verification object, in the code field of Disable TOTP, and on the backup code login route described on HTTP authentication.

Spaces and case are ignored, the hyphen is not

TEBHOS removes whitespace from a submitted backup code and lowercases it before it compares. A3F2-9KD7 and a3f2 -9kd7 match a3f2-9kd7. a3f29kd7 does not.

Authenticator secrets are credentials

Keep the TOTP secret and every backup code out of logs, analytics, URLs, and anything third parties can see. A regenerated set replaces the previous one immediately, so let the user store the new set before discarding the response.

Sudo mode

Sudo mode is a short proof that the person in front of the session is still the account holder. TEBHOS has two independent ways to provide it, and each sudo-protected route accepts exactly one of them.

Mechanism How it is proven Used by
Inline sudo The sudo verification object fields inside the JSON body of the protected request Most protected routes, including every sudo route on this page except Enable TOTP (alternate route)
Sudo grant A prior Create sudo grant call from the same session Enable TOTP (alternate route) and a few routes on other pages

A grant does not satisfy an inline route, and inline fields do not satisfy a grant route.

A request that presents no acceptable proof fails with 403 SUDO_MODE_REQUIRED:

{
  "error": "SUDO_REQUIRED",
  "code": "SUDO_MODE_REQUIRED",
  "message": "Bu işlem güvenlik onayı gerektirir."
}

The body does not list the available methods. Call Get sudo MFA methods to decide which proof to ask the user for.

A proof that is present but wrong fails with 401 UNAUTHORIZED. This 401 does not mean the session is invalid, so a client must not sign the user out on it.

Accepted proof

TEBHOS reads the first field that is present, in this order: sudo_proof, then a non-empty mfa_code, then a non-empty password.

Account state Accepted proof
No TOTP and no registered WebAuthn credential password, or mfa_code with an unconsumed backup code
TOTP enabled or at least one WebAuthn credential mfa_code (a current TOTP code or an unconsumed backup code) or sudo_proof. password alone fails with 403 SUDO_MODE_REQUIRED

mfa_code is checked as a TOTP code first and then as a backup code, and a matching backup code is consumed. sudo_proof must come from Verify passkey assertion.

The mfa_code path draws on a per-account allowance of 10 attempts per 15 minutes (auth:sudo_mfa), charged before the code is checked. The password path draws on 10 attempts per 5 minutes (auth:sudo_pwd). Both are shared by every sudo-protected route.

Sudo verification object

Inline sudo routes merge these fields into their own JSON body.

Structure

Field Type Description
password? string The account password. Accepted only while the account has no second factor
mfa_code? string A current TOTP code or an unconsumed backup code. Spaces are ignored
sudo_proof? string A proof from Verify passkey assertion. It is single-use and expires 5 minutes after issue

Sudo grant object

Structure

Field Type Description
granted_at ISO8601 timestamp When the grant was written
expires_at ISO8601 timestamp When the grant stops working, 5 minutes after granted_at

Sudo MFA methods object

Structure

Field Type Description
totp boolean Whether TOTP is enabled on the account
webauthn boolean Whether the account has at least one registered WebAuthn credential
has_mfa boolean Whether the account has any second factor: TOTP, a WebAuthn credential, or a verified phone number

Not supported on TEBHOS

The object has no backup_codes member. An account with TOTP or a passkey can always type a backup code into the mfa_code field.

MFA backup codes object

Structure

Field Type Description
backup_codes array of MFA backup code objects The account's backup codes

Example

{
  "backup_codes": [
    {"code": "a3f2-9kd7", "consumed": false},
    {"code": "b81c-4nq0", "consumed": true}
  ]
}

MFA backup code object

Structure

Field Type Description
code string One-use backup code
consumed boolean Whether the code has already been used

TOTP setup object

Structure

Field Type Description
qr_url string An otpauth://totp/ URL with issuer Tebhos and the account username as label, for rendering as a QR code
secret_base32 string The same secret in Base32, for manual entry in an authenticator app

WebAuthn credential object

An account holds at most 10 WebAuthn credentials.

Structure

Field Type Description
id string Base64url credential ID
name string User-assigned credential name (1-64 characters)
created_at ISO8601 timestamp When the credential was registered
last_used_at ?ISO8601 timestamp The most recent successful assertion, or null if the credential has never been used

TEBHOS never returns the credential's public key or signature counter.

WebAuthn registration options object

TEBHOS returns the values a client needs to build PublicKeyCredentialCreationOptions itself. The TEBHOS web client requests the ES256 (-7) and RS256 (-257) algorithms, which are the only key types TEBHOS can verify.

Structure

Field Type Description
challenge string Base64url one-use challenge, valid for 5 minutes
rp_id string Relying party identifier: the host name of the TEBHOS web app, tebhos.com
rp_name string Relying party display name, Tebhos
user_id string The account ID, to encode as the WebAuthn user handle
user_name string The account email address
user_display_name string The account display name
exclude_credential_ids array of strings Base64url IDs of credentials already registered to the account

WebAuthn assertion options object

Structure

Field Type Description
challenge string Base64url one-use challenge, valid for 5 minutes
rp_id string Relying party identifier, tebhos.com
allow_credential_ids array of strings Base64url IDs of every credential registered to the account

Passkey sudo proof object

Structure

Field Type Description
sudo_proof string A single-use proof for the sudo_proof field of the sudo verification object. It expires after 5 minutes, and a newer assertion replaces it

Get sudo MFA methods

GET /auth/sudo/mfa-methods

Returns a sudo MFA methods object describing which proofs the account can present. Sudo mode is not required.

Response

Status Body Condition
200 sudo MFA methods object The methods were returned

Rate limit

20 requests per minute for each authenticated user on the sudo:mfa:methods bucket.

Create sudo grant

POST /auth/sudo

Proves sudo mode and grants it to the calling session for 5 minutes. Returns a sudo grant object on success.

The grant belongs to the session token that made the request. It does not elevate the same account's other sessions. Calling again from the same session writes a fresh 5-minute grant.

JSON body

A sudo verification object. The accepted proof rules apply.

Response

Status Body Condition
200 sudo grant object The grant was written
401 error response The password, code, or proof was wrong
403 error response No acceptable proof was sent, returning SUDO_MODE_REQUIRED

Rate limit

5 requests per 10 seconds for each client IP on the auth:sudo bucket, plus the per-account allowances described under sudo mode.

Revoke sudo grant

DELETE /auth/sudo

Removes the calling session's sudo grant, if any. Returns 204 with an empty body.

Set up TOTP

POST /auth/2fa/totp/setup

Generates a new TOTP secret and stores it as pending. Returns a TOTP setup object. Nothing changes on the account until Enable TOTP confirms the secret.

Calling this operation again replaces the pending secret. Sudo mode is not required, because the secret has no effect until it is enabled.

Response

Status Body Condition
200 TOTP setup object A pending secret was created
409 error response TOTP is already enabled, returning TOTP_ALREADY_ENABLED

Enable TOTP

POST /auth/2fa/totp/verify

Confirms the pending secret from Set up TOTP with a current code and enables TOTP. Requires inline sudo mode. Emits a User Update Gateway event.

The response also carries the first set of 10 backup codes. Any previous set is replaced.

JSON body

The body extends the sudo verification object with:

Field Type Description
code string A current code generated from the pending secret (exactly 6 characters). A backup code is not accepted here

Response

Status Body Condition
200 object with message (string) and backup_codes (array of MFA backup code objects) TOTP was enabled
400 error response code is not 6 characters (VALIDATION), or no setup is pending (TOTP_NOT_SETUP)
401 error response The TOTP code did not match (INVALID_CODE), or the sudo proof was wrong
403 error response Sudo mode was not proven, returning SUDO_MODE_REQUIRED
409 error response TOTP is already enabled, returning TOTP_ALREADY_ENABLED

Errors from the TOTP step use the body {"error": "<CODE>", "message": "..."} with no code member.

Rate limit

10 requests per minute for each authenticated user on the user:mfa:totp:enable bucket.

Example

curl -X POST https://tebhos.com/api/v1/auth/2fa/totp/verify \
  -H "Authorization: Bearer $TEBHOS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code": "492039", "password": "correct horse battery staple"}'

Enable TOTP (alternate route)

POST /users/@me/mfa/totp/enable

Does the same as Enable TOTP but proves sudo mode with a sudo grant instead of inline fields, and returns only the MFA backup codes object.

JSON body

Field Type Description
code string A current code generated from the pending secret (1-32 characters)
secret? string Accepted for compatibility and ignored. The pending secret from Set up TOTP is used

Response

Status Body Condition
200 MFA backup codes object TOTP was enabled and 10 backup codes were issued
403 error response The session holds no valid sudo grant, returning SUDO_MODE_REQUIRED

The other failures match Enable TOTP.

Rate limit

10 requests per minute for each authenticated user on the user:mfa:totp:enable bucket.

Disable TOTP

POST /users/@me/mfa/totp/disable

Disables TOTP and deletes every backup code. Returns 204 with an empty body. Emits a User Update Gateway event.

The code field is the identity proof: a current TOTP code or an unconsumed backup code. No other sudo field is read.

JSON body

Field Type Description
code string A current TOTP code or an unconsumed backup code (1-32 characters). Spaces are ignored

Backup codes do not survive

Disabling TOTP deletes the whole backup code set, including when the account keeps passkeys. Call List MFA backup codes with regenerate set to true to issue a new set.

Response

Status Body Condition
204 empty TOTP was disabled
401 error response The code matched neither TOTP nor a backup code, returning INVALID_CODE

Rate limit

10 requests per minute for each authenticated user on the user:mfa:totp:disable bucket.

List MFA backup codes

POST /users/@me/mfa/backup-codes

Returns an MFA backup codes object, replacing the set first when regenerate is true. Requires inline sudo mode.

Neither TOTP nor a verified email is required, so an account with no second factor can prove sudo mode with its password and create a set.

A backup code sent as mfa_code to prove sudo mode is consumed. With regenerate set to false it comes back with consumed set to true.

JSON body

The body extends the sudo verification object with:

Field Type Description
regenerate? boolean Whether to discard the current set and issue 10 replacements. Defaults to false

Response

Status Body Condition
200 MFA backup codes object The current or replacement codes were returned
401 error response The sudo proof was wrong
403 error response Sudo mode was not proven, returning SUDO_MODE_REQUIRED

Rate limit

6 requests per minute for each authenticated user on the user:mfa:backup_codes bucket, shared with Regenerate MFA backup codes and Count MFA backup codes.

Regenerate MFA backup codes

POST /auth/2fa/backup-codes/regenerate

Replaces the backup code set with 10 new codes and returns them as plain strings. Requires inline sudo mode. A request without a body is treated as presenting no proof.

JSON body

A sudo verification object.

Response

Status Body Condition
200 object with codes (array of strings) and generated_at (ISO8601 timestamp) The set was replaced
403 error response Sudo mode was not proven, returning SUDO_MODE_REQUIRED

Rate limit

6 requests per minute for each authenticated user on the user:mfa:backup_codes bucket.

Count MFA backup codes

GET /auth/2fa/backup-codes/list

Returns how many backup codes are still unused, without revealing them. Sudo mode is not required.

Response

Status Body Condition
200 object with active_count (integer) and total_count (integer, always 10) The count was returned

Rate limit

6 requests per minute for each authenticated user on the user:mfa:backup_codes bucket.

List WebAuthn credentials

GET /auth/passkeys

Returns the account's credentials, oldest first, as {"passkeys": [...]} with WebAuthn credential objects. Sudo mode is not required.

GET /users/@me/mfa/webauthn/credentials returns the same credentials as a bare array.

Response

Status Body Condition
200 object with passkeys (array of WebAuthn credential objects) The credentials were returned, possibly as an empty array

Rate limit

40 requests per 10 seconds for each authenticated user on the mfa:webauthn:list bucket. Both routes share it.

Create WebAuthn registration options

POST /auth/passkeys/register-options

Creates a one-use registration challenge and returns a WebAuthn registration options object. Requires inline sudo mode. A request without a body is treated as presenting no proof.

A new challenge replaces any earlier registration challenge for the account.

JSON body

A sudo verification object.

Response

Status Body Condition
200 WebAuthn registration options object The challenge was issued
400 error response The account already has 10 credentials
403 error response Sudo mode was not proven, returning SUDO_MODE_REQUIRED

Rate limit

20 requests per 10 seconds for each authenticated user on the mfa:webauthn:registration_options bucket.

Register WebAuthn credential

POST /auth/passkeys/register

Stores the credential the browser created for the registration challenge. Returns the new WebAuthn credential object without last_used_at. Emits a WebAuthn Credentials Update Gateway event with the full credential list, and a User Update event.

This route reads no sudo field. The challenge can only come from Create WebAuthn registration options, which required sudo mode.

TEBHOS checks that the client data type is webauthn.create, that its challenge is the account's current registration challenge, and that its origin is the TEBHOS web app (https://tebhos.com). The attestation statement is not verified. The first credential adds the WebAuthn authenticator type.

JSON body

Field Type Description
name string Credential name (1-64 characters after trimming)
credential_id string credential.id from the browser, base64url
client_data_json string response.clientDataJSON, base64url
attestation_object string response.attestationObject, base64url

Response

Status Body Condition
200 WebAuthn credential object The credential was registered
400 error response The client data, challenge, or origin was rejected, the name is out of range, or the account already has 10 credentials

A failed registration leaves the challenge in place until it expires. A credential ID that is already registered to any account is not stored again, and the request still returns 200.

Rate limit

10 requests per minute for each authenticated user on the mfa:webauthn:register bucket.

Rename WebAuthn credential

PATCH /auth/passkeys/{credential_id}

Changes a credential's name. Requires inline sudo mode. Emits a WebAuthn Credentials Update Gateway event.

Path parameters

Field Type Description
credential_id string Base64url ID of a credential registered to the account

JSON body

The body extends the sudo verification object with:

Field Type Description
name string The new name (1-64 characters after trimming)

Response

Status Body Condition
200 object with a message string The credential was renamed
403 error response Sudo mode was not proven, returning SUDO_MODE_REQUIRED
404 error response No such credential on the account

Rate limit

20 requests per 10 seconds for each authenticated user on the mfa:webauthn:update bucket.

Delete WebAuthn credential

DELETE /auth/passkeys/{credential_id}

Deletes a credential. Requires inline sudo mode, sent as the JSON body of the DELETE request. Emits a WebAuthn Credentials Update Gateway event and a User Update event.

Sudo mode is checked before TEBHOS looks the credential up, so a request without a proof learns nothing about the credential.

Removing the last credential removes a second factor

Deleting the last WebAuthn credential removes the WebAuthn authenticator type. Without TOTP the account then has no second factor, and sudo mode falls back to the account password.

Path parameters

Field Type Description
credential_id string Base64url ID of a credential registered to the account

JSON body

A sudo verification object.

Response

Status Body Condition
200 object with a message string The credential was deleted
403 error response Sudo mode was not proven, returning SUDO_MODE_REQUIRED
404 error response No such credential on the account

Rate limit

10 requests per minute for each authenticated user on the mfa:webauthn:delete bucket.

Create passkey assertion options

POST /auth/passkeys/assert-options

Creates a one-use assertion challenge for a signed-in account and returns a WebAuthn assertion options object. Use it to turn a passkey into a sudo proof with Verify passkey assertion. Send an empty body.

Response

Status Body Condition
200 WebAuthn assertion options object The challenge was issued
400 error response The account has no registered credential

Note

POST /auth/sudo/webauthn/authentication-options returns the same object with a separate sudo challenge, but no TEBHOS route accepts that challenge yet. Use this route instead.

Verify passkey assertion

POST /auth/passkeys/assert

Verifies a WebAuthn assertion for the challenge from Create passkey assertion options and returns a passkey sudo proof object.

TEBHOS checks that the client data type is webauthn.get, that the challenge and the origin match, that the credential belongs to the account, and the signature. Supported keys are ES256 and RS256. The signature counter must be greater than the stored counter, except when both are zero, which is how an authenticator without a counter reports. On success the counter and last_used_at are updated and the challenge is consumed.

JSON body

Field Type Description
credential_id string credential.id from the browser, base64url
client_data_json string response.clientDataJSON, base64url
authenticator_data string response.authenticatorData, base64url
signature string response.signature, base64url

Response

Status Body Condition
200 passkey sudo proof object The assertion was valid
400 error response The client data, challenge, or origin was rejected
401 error response The signature did not verify or the counter did not advance
404 error response The credential is not registered to the account

Rate limit

10 requests per 5 minutes for each authenticated user on the auth:pk_assert bucket.

Example

const base = "https://tebhos.com/api/v1";
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64u = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));

const opts = await fetch(`${base}/auth/passkeys/assert-options`, { method: "POST", headers })
  .then((r) => r.json());

const cred = await navigator.credentials.get({
  publicKey: {
    challenge: fromB64u(opts.challenge),
    rpId: opts.rp_id,
    allowCredentials: opts.allow_credential_ids.map((id) => ({ type: "public-key", id: fromB64u(id) })),
    userVerification: "preferred",
  },
});

const { sudo_proof } = await fetch(`${base}/auth/passkeys/assert`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    credential_id: cred.id,
    client_data_json: b64u(cred.response.clientDataJSON),
    authenticator_data: b64u(cred.response.authenticatorData),
    signature: b64u(cred.response.signature),
  }),
}).then((r) => r.json());

// Use it once, within 5 minutes, on an inline sudo route.
await fetch(`${base}/users/@me/mfa/backup-codes`, {
  method: "POST",
  headers,
  body: JSON.stringify({ regenerate: false, sudo_proof }),
});