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 builds one of the templates below, signs it, and submits it:

  1. Build the message from the sign-in template (or a registration template for a new account), filling in the wallet address and Date.now().
  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, the exact message string, and the signature.

The message must match one of these three templates byte for byte — same wording, same blank lines, no leading or trailing whitespace, no trailing newline, \n line endings (not \r\n).

1. Sign in (returning user)
Zentalk Sign In
I confirm that I own this wallet and want to sign in to Zentalk.
Wallet: 0x1234567890abcdef1234567890abcdef12345678
Timestamp: 1768387200000
2. Registration (new user, with display name)
Zentalk Registration
I confirm that I own this wallet and want to register with Zentalk.
Wallet: 0x1234567890abcdef1234567890abcdef12345678
Username: alice
Name: Alice Smith
Timestamp: 1768387200000
3. Registration, short form (in-app embedded-wallet signup)
Zentalk Registration
Wallet: 0x1234567890abcdef1234567890abcdef12345678
Username: alice
Timestamp: 1768387200000
Field Rule
Wallet: 0x + 40 hex characters, any case. Must resolve to the same address as the wallet_address request field.
Username: One line of free text. When the request body also carries username, the two must be identical.
Name: One line of free text; not cross-checked against the request body.
Timestamp: Milliseconds since the epoch (Date.now()), 1–19 digits, greater than zero. A seconds-resolution value parses but is read as 1970 and rejected as expired.
Wallet login
curl -X POST http://localhost:3001/api/v1/initialize \
-H "Content-Type: application/json" \
-d '{
"wallet_address": "0x1234567890abcdef1234567890abcdef12345678",
"username": "alice",
"message": "Zentalk Sign In\n\nI confirm that I own this wallet and want to sign in to Zentalk.\n\nWallet: 0x1234567890abcdef1234567890abcdef12345678\nTimestamp: 1768387200000",
"signature": "0xabcdef..."
}'

Server-side protections on this endpoint, applied in this order:

Protection Behavior
Message template The signed string must match one of the three templates; anything else is rejected with 401 UNAUTHORIZED before the signature is checked.
Wallet binding The address inside the signed message must match wallet_address; for the registration templates, the username inside the message must match the request username when both are present.
Signature verification EIP-191 personal_sign recovery: the address recovered from the signature must equal wallet_address. The malleable high-S form of a signature is rejected (EIP-2 low-S).
Timestamp freshness The timestamp comes from the matched template and is mandatory. More than 5 minutes away from server time in either direction is rejected (EXPIRED_SIGNATURE).
Replay cache An accepted signature is marked spent for 10 minutes and rejected on re-use (REPLAY_DETECTED). The cache is keyed on the canonical signature encoding, so re-encoding it (hex case, 0x prefix, v in {0,1} vs {27,28}) does not make it look new.
Rate limiting + CAPTCHA The endpoint is auth-rate-limited; a CAPTCHA gate wraps it when enabled by the operator.
Proof-of-work Sybil gate When the operator sets ZENTALK_POW_DIFFICULTY_BITS > 0, new-account creation must include a pow_nonce such that sha256(message || nonce) has at least that many leading zero bits (client difficulty: NEXT_PUBLIC_POW_DIFFICULTY_BITS, same value). Returning users are exempt. Default 0 (off).

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 30-day sliding lifetime (ZENTALK_SESSION_TTL, default 720 hours): every validated activity extends the window back to the full TTL, so an active user stays signed in until explicit logout. An optional idle timeout (ZENTALK_SESSION_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 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.

There is no separate cookie-login endpoint. POST /api/v1/initialize is the only route that creates a cookie session: it sets the SameSite/HttpOnly session cookie and the CSRF cookie as part of registration. A former POST /api/v1/auth/session route was removed — it verified a signature over a free-form, client-supplied message with no template binding, no freshness check and no replay claim, and its verifier rejected every well-formed signature, so it was deleted rather than repaired. The remaining session routes read or end an existing session; none of them create one.

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.