Skip to content

Authentication

Zentalk authenticates users with an Ethereum wallet signature or a verified phone number, then manages the session with short-lived JWTs. This page covers every auth surface in the API.

The OpenAPI specification defines three security schemes:

Scheme Header Purpose
BearerAuth Authorization: Bearer <jwt> Primary scheme. Short-lived JWT access token identifying the session.
SignatureAuth X-Signature Ethereum wallet signature for request-level authentication on signature-gated operations.
WalletAuth X-Wallet-Address Wallet address header. Informational only: since the IDOR remediation, the server derives identity from the JWT subject; this header is accepted only when it equals the JWT subject and is otherwise dropped. Never rely on it to select an identity — the JWT is authoritative.

Every route is registered under both /api/v1/ (current, recommended) and the unversioned /api/ prefix (legacy, deprecated); the endpoint tables below use the /api/v1/ paths. See Versioning.

There is no server-issued challenge for wallet login. The client constructs the challenge itself, signs it, and submits it:

  1. Build a login message containing a Timestamp: <unix-ms> line.
  2. Sign it with the wallet (personal_sign, EIP-191 — the server hashes the message with the \x19Ethereum Signed Message:\n prefix and recovers the signer address).
  3. POST /api/v1/initialize with the address, message, and signature.
Wallet login
curl -X POST http://localhost:3001/api/v1/initialize \
-H "Content-Type: application/json" \
-d '{
"wallet_address": "0x1234...5678",
"username": "alice",
"message": "Login to Zentalk\nTimestamp: 1768387200000",
"signature": "0xabcdef..."
}'

Server-side protections on this endpoint:

Protection Behavior
Timestamp freshness A Timestamp: line older than 5 minutes is rejected (EXPIRED_SIGNATURE).
Replay cache Each signature is single-use for 10 minutes (REPLAY_DETECTED).
Recovered address check The address recovered from the signature must match wallet_address.
Rate limiting + CAPTCHA The endpoint is auth-rate-limited; a CAPTCHA gate wraps it when enabled by the operator.

A successful response includes access_token, refresh_token, expires_at, and token_type: "Bearer". Calling /api/v1/initialize without signature/message acts as a status probe: it returns 401 (existing user — signature required) or 400 (new user — username required).

An existing session can also be resumed with a valid JWT instead of a fresh signature via POST /session/resume (BearerAuth).

Phone auth is an alternative registration and login path. All endpoints are under the Authentication tag in the REST API reference.

Step Endpoint Notes
1. Request SMS code POST /api/v1/auth/phone/send-sms CAPTCHA-gated; rate-limited per phone number and per IP.
2. Verify code POST /api/v1/auth/phone/verify-code Verifies the code sent in step 1.
3a. Register POST /api/v1/auth/phone/register Creates an account bound to the verified phone number. Returns 409 if already registered.
3b. Login challenge POST /api/v1/auth/phone/login-challenge Requests a challenge for phone-based login.
4. Login POST /api/v1/auth/phone/login Completes login using the verified challenge response.
Lockout status GET /api/v1/auth/phone/lockout-status?phone=... Returns failed-attempt count and lock state for a number.

Supporting endpoints: POST /api/v1/auth/migrate-phone-hash (upgrades a legacy V1 phone hash, PBKDF2-100k, to the V2 scheme, PBKDF2-310k; BearerAuth) and POST /api/v1/auth/phone-revoke-abandoned (revokes a partially completed registration attempt).

Phone numbers are never stored raw:

  • Hashing — the client derives a PBKDF2-SHA256 hash on the device (310,000 iterations in the V2 scheme, 100,000 in legacy V1) using a fixed, public salt.
  • Enumeration caveat — the hash is deliberately deterministic (contact discovery requires the same number to always produce the same hash), which limits resistance to offline enumeration of the phone-number space; the iteration count raises the cost of such enumeration but does not eliminate it.
  • Registration binding — the client sends the phone hash signed with the user’s identity key; the backend currently runs this check in warn mode and accepts registrations without it.
  • Login — the server verifies the challenge signature against the identity key.
Token Lifetime Notes
Access token 15 minutes Sent as Authorization: Bearer <token> on every API call.
Refresh token 7 days Exchanged for new token pairs; never sent as a Bearer token.
Refresh an expired access token
curl -X POST http://localhost:3001/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "eyJhbGciOi..."}'
Operation Endpoint Auth / notes
Refresh access token POST /api/v1/auth/refresh Body: {"refresh_token": "..."}
Revoke a refresh token POST /api/v1/auth/revoke BearerAuth + body {"refresh_token": "..."}

Refresh tokens are organized in families with parent-chain tracking: presenting an already-used refresh token revokes the entire family. Optionally, refresh tokens can be DPoP-bound to a client-held key (RFC 9449); this is opt-in via NEXT_PUBLIC_DPOP_ENABLED=true in the web client.

Sessions have a fixed 7-day lifetime from creation; an idle timeout exists but is disabled by default. A user can hold at most 5 concurrent device sessions.

Operation Endpoint Auth / notes
List active sessions GET /api/v1/auth/sessions BearerAuth
Revoke one session DELETE /api/v1/auth/sessions/{id} BearerAuth
Revoke all sessions POST /api/v1/auth/logout-all BearerAuth
Token logout (current session) POST /api/v1/logout BearerAuth
Cookie session login POST /api/v1/auth/session Starts a SameSite, HttpOnly cookie session (browser clients).
Cookie session logout POST /api/v1/auth/session/logout BearerAuth; clears cookies and blacklists the current access token.
Current session info GET /api/v1/session/me Cookie-based session check.

Per-device refresh scoping adds a device registry:

Operation Endpoint
Register a device POST /api/v1/auth/register-device
List devices GET /api/v1/auth/devices
Revoke a device DELETE /api/v1/auth/devices/{id}

Browser clients using cookie sessions need a CSRF token. Rotate it with:

Refresh CSRF token
curl -X POST http://localhost:3001/api/v1/auth/csrf \
-H "Authorization: Bearer $ZENTALK_TOKEN"

Short-lived tickets are the recommended way to authenticate WebSocket connections; a JWT subprotocol fallback also exists. Request a ticket first:

Get a WebSocket ticket
curl -X POST http://localhost:3001/api/v1/auth/ws-ticket \
-H "Authorization: Bearer $ZENTALK_TOKEN"
Response
{
"ticket": "",
"expires_in": 30
}

The ticket is single-use and expires in 30 seconds — request it immediately before connecting to /ws. See WebSocket for the connection handshake and event format.

MFA is TOTP-based with backup codes and trusted devices. All endpoints require BearerAuth.

Operation Endpoint Notes
Begin TOTP setup POST /api/v1/mfa/totp/setup Returns a provisioning URI and secret for the authenticator app.
Enable TOTP POST /api/v1/mfa/totp/enable Confirms setup with a one-time code.
MFA status GET /api/v1/mfa/status Whether MFA is enabled for the account.
Create login challenge POST /api/v1/mfa/challenge Issued during login when MFA is enabled.
Verify challenge POST /api/v1/mfa/verify Completes the MFA step.
Disable MFA POST /api/v1/mfa/disable Requires verification.
Backup codes status GET /api/v1/mfa/backup-codes/status Total, remaining, and used counts.
Regenerate backup codes POST /api/v1/mfa/backup-codes/regenerate Requires TOTP verification; invalidates old codes.
Trusted devices GET /api/v1/mfa/trusted-devices, DELETE /api/v1/mfa/trusted-devices/{deviceId} Manage devices that skip the MFA challenge.
Lockout status GET /api/v1/mfa/lockout-status Failed-attempt lockout state.

Authentication attempts are limited to 5 per minute, and 429 responses carry Retry-After plus X-RateLimit-Limit and X-RateLimit-Window headers. See Rate limits for the full matrix.