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¶
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¶
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¶
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 }),
});