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.
Security schemes
Section titled “Security schemes”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. |
Path versioning
Section titled “Path versioning”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.
Wallet login
Section titled “Wallet login”There is no server-issued challenge for wallet login. The client builds one of the templates below, signs it, and submits it:
- Build the message from the sign-in template (or a registration template for a new account), filling in the wallet address and
Date.now(). - Sign it with the wallet (
personal_sign, EIP-191 — the server hashes the message with the\x19Ethereum Signed Message:\nprefix and recovers the signer address). POST /api/v1/initializewith the address, the exact message string, and the signature.
Accepted messages
Section titled “Accepted messages”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).
Zentalk Sign In
I confirm that I own this wallet and want to sign in to Zentalk.
Wallet: 0x1234567890abcdef1234567890abcdef12345678Timestamp: 1768387200000Zentalk Registration
I confirm that I own this wallet and want to register with Zentalk.
Wallet: 0x1234567890abcdef1234567890abcdef12345678Username: aliceName: Alice SmithTimestamp: 1768387200000Zentalk Registration
Wallet: 0x1234567890abcdef1234567890abcdef12345678Username: aliceTimestamp: 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. |
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 authentication
Section titled “Phone authentication”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.
JWT lifecycle
Section titled “JWT lifecycle”| 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. |
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 and devices
Section titled “Sessions and devices”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:
curl -X POST http://localhost:3001/api/v1/auth/csrf \ -H "Authorization: Bearer $ZENTALK_TOKEN"WebSocket tickets
Section titled “WebSocket tickets”Short-lived tickets are the recommended way to authenticate WebSocket connections; a JWT subprotocol fallback also exists. Request a ticket first:
curl -X POST http://localhost:3001/api/v1/auth/ws-ticket \ -H "Authorization: Bearer $ZENTALK_TOKEN"{ "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.
Multi-factor authentication
Section titled “Multi-factor authentication”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. |
Rate limits
Section titled “Rate limits”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.
Next steps
Section titled “Next steps”- Environments and base URLs — where to point your authenticated requests
- WebSocket connection — use your session for real-time events
- REST API reference — every authenticated endpoint
