End User Authentication
These endpoints handle the core authentication lifecycle for your application's end users: registration, login, session management, and user profiles.
Authentication Headers
End user auth endpoints use application credentials, not dashboard JWTs:
| Header | Value | Required |
|---|---|---|
X-Application-Key | Your app's public key (za_live_pub_xxxx) | Always |
X-Application-Secret | Your app's secret key (za_live_sec_xxxx) | Server-side endpoints |
Authorization | Bearer <access_token> | User-authenticated endpoints |
Content-Type | application/json | POST/PATCH requests |
Client-safe session endpoints — login, refresh, and revoke (logout) — need only the public key, so a client app can run the whole session lifecycle without a backend. Login is protected by rate limiting, account lockout, and abuse detection (not by the secret). See Refresh Token.
Server-side endpoints (register, magic-link, phone/email-OTP send/verify, the user directory) require both the public key and secret key.
User-authenticated endpoints (profile, sessions) require the public key plus the user's access token. No secret key needed — safe for client-side use.
Email verification send/resend are the exception that need all three at once (public key + secret + user token), because they act on behalf of a specific signed-in user. See Email Verification.
For the full per-endpoint credential breakdown, see the End-User Field & Endpoint Matrix.
Password Requirements
Before showing a registration form, fetch the application's password requirements for client-side validation:
curl https://api.zyphr.dev/v1/auth/password-requirements \
-H "X-Application-Key: za_live_pub_xxxx"
const response = await fetch('https://api.zyphr.dev/v1/auth/password-requirements', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
},
});
const { data } = await response.json();
// data.requirements — { min_length, require_uppercase, require_lowercase, require_numbers, require_special }
This endpoint only requires the public key — no secret needed. Safe to call from the frontend.
Registration
curl -X POST https://api.zyphr.dev/v1/auth/users/register \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"password": "SecureP@ss123",
"name": "Jane Doe",
"metadata": { "plan": "pro", "source": "landing-page" }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/users/register', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'jane@example.com',
password: 'SecureP@ss123',
name: 'Jane Doe',
metadata: { plan: 'pro', source: 'landing-page' },
}),
});
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | User's email address |
password | string | Yes | Must meet application's password requirements |
name | string | No | Display name |
metadata | object | No | Arbitrary key-value data attached to the user |
Response (201 Created)
{
"data": {
"user": {
"id": "usr_abc123",
"email": "jane@example.com",
"name": "Jane Doe",
"email_verified": false,
"metadata": { "plan": "pro", "source": "landing-page" },
"created_at": "2025-01-15T10:00:00Z"
},
"tokens": {
"access_token": "eyJhbGci...",
"refresh_token": "zrt_xxxx",
"expires_in": 3600,
"token_type": "Bearer"
}
}
}
Error Responses
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | Missing or invalid email/password |
| 400 | password_validation_error | Password doesn't meet requirements (includes errors array and requirements) |
| 409 | conflict | Email already registered |
Login
curl -X POST https://api.zyphr.dev/v1/auth/users/login \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"password": "SecureP@ss123"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/users/login', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'jane@example.com',
password: 'SecureP@ss123',
}),
});
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | User's email |
password | string | Yes | User's password |
custom_claims | object | No | Custom data to embed in the JWT (max 4KB). Must be a flat object. |
Standard Response (No MFA)
{
"data": {
"mfa_required": false,
"user": {
"id": "usr_abc123",
"email": "jane@example.com",
"name": "Jane Doe",
"email_verified": true,
"avatar_url": null,
"metadata": { "plan": "pro" },
"last_login_at": "2025-01-15T10:00:00Z",
"mfa_enabled": false
},
"tokens": {
"access_token": "eyJhbGci...",
"refresh_token": "zrt_xxxx",
"expires_in": 3600,
"token_type": "Bearer"
}
}
}
MFA-Required Response
When the user has MFA enabled, login returns a challenge token instead of auth tokens:
{
"data": {
"mfa_required": true,
"user": {
"id": "usr_abc123",
"email": "jane@example.com"
},
"mfa_challenge": {
"token": "mfa_challenge_xxxx",
"expires_at": "2025-01-15T10:05:00Z"
}
},
"meta": {
"message": "MFA verification required. Use POST /v1/auth/mfa/verify with the challenge token."
}
}
See Multi-Factor Authentication for completing the MFA flow.
Error Responses
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | Missing email/password or invalid custom_claims |
| 401 | unauthorized | Invalid email or password, or account is not active |
| 403 | account_locked | Too many failed attempts. Includes locked_until and attempt_count. |
| 403 | session_limit_exceeded | Max concurrent sessions reached. Includes max_sessions and current_sessions. |
Session Management
Refresh Token
Exchange a refresh token for new access and refresh tokens. This endpoint is
publishable-key-safe — it needs only X-Application-Key (no application
secret), because the refresh token itself is the credential. That means client
apps (browser / mobile) can refresh directly:
curl -X POST https://api.zyphr.dev/v1/auth/sessions/refresh \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "zrt_xxxx",
"custom_claims": { "role": "admin" }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/sessions/refresh', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY, // publishable key only
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: storedRefreshToken,
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
refresh_token | string | Yes | The refresh token from login or previous refresh |
custom_claims | object | No | Update JWT claims. If omitted, preserves existing claims. |
Each successful refresh returns a new refresh_token and invalidates the one
you presented (single-use rotation — it limits replay of a stolen token).
Your client must persist the new access_token and refresh_token
atomically before the next refresh. If you lose the new refresh token (e.g. the
app crashes after the response but before you save it), the next refresh presents
the old, now-revoked token and gets a 401 — which looks like an expired
session but is actually a lost-token bug.
If you use @zyphr-dev/auth-core (and the auth-react / auth-react-native
bindings), its SessionManager stores the rotated tokens for you and dedupes
concurrent refreshes. Hand-rolled clients must replicate this: save first, then
proceed.
Sending X-Application-Secret on refresh is harmless (backward compatible) — it
is simply no longer required. If you previously refreshed from a server with the
secret, that keeps working unchanged.
Revoke Session (Logout)
curl -X POST https://api.zyphr.dev/v1/auth/sessions/revoke \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "refresh_token": "zrt_xxxx" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/sessions/revoke', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: storedRefreshToken }),
});
Always returns success (even for invalid tokens) to prevent token enumeration.
Revoke All Sessions
Requires the user's access token. Revokes every active session for the authenticated user:
curl -X POST https://api.zyphr.dev/v1/auth/sessions/revoke-all \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json"
const response = await fetch('https://api.zyphr.dev/v1/auth/sessions/revoke-all', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
// response.data.sessions_revoked — number of sessions revoked
List Active Sessions
curl https://api.zyphr.dev/v1/auth/sessions \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
const response = await fetch('https://api.zyphr.dev/v1/auth/sessions', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
},
});
Returns a list of active sessions with device info (user agent, IP, created/last used timestamps).
Password Management
Zyphr exposes three distinct password endpoints. They are not interchangeable — pick the one that matches what the user knows and how they are (or aren't) authenticated:
| Endpoint | Auth | When to use |
|---|---|---|
POST /v1/auth/forgot-password | App credentials | User forgot their password. Emails a reset link. |
POST /v1/auth/reset-password | App credentials | Consumes the emailed token and sets a new password. |
POST /v1/auth/users/change-password | Public key + user token | User is signed in and knows their current password. |
forgot-password→reset-passwordis the two-step recovery flow for a user who is locked out.forgot-passwordalways returns success (to prevent email enumeration) and, if the address matches a real user, emails a reset link. The user then submits the token from that link toreset-password. See Password Reset below.change-passwordis the in-session flow for a user who is already logged in and wants to rotate their password. It requires the user's access token and proof of the current password — no email round-trip.
Change Password (Authenticated Session)
Changes the signed-in user's password. Uses the public key + the user's access token (no secret key) — safe for client-side use. The user is identified from the access token, never from the request body, so a user can only ever change their own password.
curl -X POST https://api.zyphr.dev/v1/auth/users/change-password \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"current_password": "OldP@ss123",
"new_password": "NewSecureP@ss456"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/users/change-password', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
current_password: 'OldP@ss123',
new_password: 'NewSecureP@ss456',
}),
});
const { data } = await response.json();
// data.tokens — a FRESH token pair; replace your stored tokens with these.
import { ZyphrClient } from '@zyphr-dev/node-sdk';
const zyphr = new ZyphrClient({
applicationKey: process.env.ZYPHR_APP_PUBLIC_KEY, // za_live_pub_*
accessToken, // applied automatically
});
// Pass camelCase arguments — the SDK serializes them to the snake_case wire
// body (current_password / new_password) for you.
const result = await zyphr.auth.profile.changeEndUserPassword({
currentPassword: 'OldP@ss123',
newPassword: 'NewSecureP@ss456',
});
zyphr.setAccessToken(result.data.tokens.access_token); // store the fresh token
The REST endpoint's wire contract is snake_case (current_password /
new_password). The typed changeEndUserPassword method takes camelCase
arguments and converts them for you — posting { currentPassword, newPassword }
directly to the endpoint is rejected with current_password is required. See
Client-side Auth.
| Parameter | Type | Required | Description |
|---|---|---|---|
current_password | string | Yes | The user's existing password. Verified server-side. |
new_password | string | Yes | The new password. Must meet the application's password requirements. |
On success, Zyphr:
- Verifies
current_passwordagainst the stored hash. - Updates the password.
- Revokes all existing sessions for the user (every other logged-in device is signed out).
- Returns a fresh token pair so the calling session stays authenticated — replace your stored
access_token/refresh_tokenwith the new ones. - Emits the
user.password_changedwebhook (withmethod: "session").
Response (200 OK)
{
"data": {
"user": {
"id": "usr_abc123",
"email": "jane@example.com",
"name": "Jane Doe",
"email_verified": true,
"avatar_url": null,
"metadata": { "plan": "pro" }
},
"tokens": {
"access_token": "eyJhbGci...",
"refresh_token": "zrt_xxxx",
"expires_in": 3600,
"token_type": "Bearer"
}
}
}
Error Responses
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | Missing current_password or new_password |
| 400 | password_validation_error | New password doesn't meet requirements (includes errors array and requirements) |
| 400 | no_password_set | The account has no password to change (e.g. an OAuth-only account). Route the user through your set-password / OAuth-link flow instead. |
| 401 | invalid_current_password | The supplied current_password is incorrect |
Because a password change revokes all existing sessions, other devices must sign in again. The calling session survives only because a new token pair is issued in the response — be sure to persist it.
Password Reset
The two-step recovery flow for a user who has forgotten their password. Both endpoints use application credentials (public key + secret key) and are server-side.
Request a Reset (forgot-password)
Emails a reset link to the user. Always returns success — even for an unknown address — to prevent email enumeration.
curl -X POST https://api.zyphr.dev/v1/auth/forgot-password \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "email": "jane@example.com" }'
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The user's email address |
redirect_url | string | No | Where the reset link should point. Must match one of the application's allowed redirect URIs. |
Consume the Token (reset-password)
Sets a new password using the token from the reset email.
curl -X POST https://api.zyphr.dev/v1/auth/reset-password \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"token": "RESET_TOKEN_FROM_EMAIL",
"new_password": "NewSecureP@ss456"
}'
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The reset token delivered in the email |
new_password | string | Yes | The new password. Must meet the application's password requirements. |
Like change-password, a successful reset revokes all of the user's existing sessions and emits user.password_changed. Unlike change-password, it does not return a token pair — the user logs in fresh with their new password.
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | Missing token or new_password |
| 400 | password_validation_error | New password doesn't meet requirements |
| 400 | invalid_token | The reset token is invalid or expired |
Email Verification
Verify an authenticated user's email address. There are three endpoints —
verify-email/send, verify-email/confirm, and verify-email/resend — but they are
not a mandatory matched set: for the common "click the emailed link" flow you only need
send. See The emailed link verifies on click below.
Credentials (all three required)
Unlike most endpoints, verify-email/send (and resend) act on behalf of a specific
signed-in user, so they require all three credentials at once:
| Header | Value |
|---|---|
X-Application-Key | Your app's public key (za_live_pub_xxxx) |
X-Application-Secret | Your app's secret key (za_live_sec_xxxx) |
Authorization | Bearer <the user's access token> |
This three-credential combination isn't reachable through either SDK client today — the full
Zyphr client carries application credentials but no user access token, and ZyphrClient
carries a token but deliberately refuses secret-shaped credentials. Call verify-email/send
with raw HTTP (as below) until a client supports carrying all three.
Send the verification email (verify-email/send)
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/send \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Authorization: Bearer USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "redirect_url": "https://yourapp.example.com/verified" }'
| Parameter | Type | Required | Description |
|---|---|---|---|
redirect_url | string | No | Where the verification link returns the user after it verifies. Must match one of the application's allowed redirect URIs. |
redirect_url (snake_case)Because you're hand-rolling this request, you own the serialization — the field is
redirect_url, not redirectUrl. A camelCase redirectUrl is silently ignored, and
Zyphr falls back to its own hosted verification page instead of returning to your app.
Allowlist suppression is a 200, not an error. If the recipient is blocked by your
application's auth_email_mode: allowlist gate, send still returns 200 — it accepts the
request and suppresses the send (mirroring forgot-password). The response
carries suppressed: true and status_reason: "recipient_not_allowlisted", and the
suppression is visible on the message record and the
email.suppressed webhook. In staging, where
the allowlist is populated by design, a non-allowlisted recipient is the normal case — so this
is not an outage.
The emailed link verifies on click
The link in the email verifies the address server-side, on click, and then redirects to
your redirect_url. So by the time your app lands the redirect, the address is already
verified and the token is spent.
This makes the obvious implementation — "take the token off the redirect and call confirm" —
report failure in the success case. The recommended pattern for a redirect landing:
Read the user's verified state back and report that. Optionally call
confirmfirst and ignore its result — it is idempotent (see below) — then check the user'semail_verifiedvia Get Current User. This is correct whether or not the link already consumed the token, without your client needing to know which.
Confirm a token you collected yourself (verify-email/confirm)
Use confirm when you collect the token directly (not from the hosted redirect). It takes the
application key + secret (no user token):
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/confirm \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "X-Application-Secret: za_live_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "token": "TOKEN_FROM_EMAIL" }'
confirm is idempotent: if the token was already redeemed (e.g. the link consumed it on
click) and the user is verified, it returns 200 with already_verified: true — not a 400.
Only a genuinely invalid, expired, or unknown token returns 400 invalid_token.
| Status | Response | Cause |
|---|---|---|
| 200 | { success: true, already_verified: false, email } | Token freshly verified |
| 200 | { success: true, already_verified: true, email } | Already verified (link consumed the token on click) |
| 400 | validation_error | Missing token |
| 400 | invalid_token | Token is genuinely invalid, expired, or unknown |
Resend (verify-email/resend)
verify-email/resend re-sends the verification email (same three credentials as send),
rate-limited to once per 60 seconds.
User Profile
These endpoints use the public key + user access token (no secret key needed).
Get Current User
curl https://api.zyphr.dev/v1/auth/users/me \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
const response = await fetch('https://api.zyphr.dev/v1/auth/users/me', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
},
});
Response
{
"data": {
"user": {
"id": "usr_abc123",
"email": "jane@example.com",
"name": "Jane Doe",
"email_verified": true,
"avatar_url": "https://example.com/avatar.jpg",
"metadata": { "plan": "pro" },
"created_at": "2025-01-15T10:00:00Z",
"last_login_at": "2025-01-20T08:30:00Z"
}
}
}
Update Profile
curl -X PATCH https://api.zyphr.dev/v1/auth/users/me \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Smith",
"avatar_url": "https://example.com/new-avatar.jpg",
"metadata": { "plan": "enterprise" }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/users/me', {
method: 'PATCH',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Jane Smith',
metadata: { plan: 'enterprise' },
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | No | Updated display name |
avatar_url | string | No | Profile image URL |
metadata | object | No | Replaces existing metadata |
Delete Account (GDPR Self-Service)
Users can delete their own account. This is a soft delete — the account is marked as deleted and all sessions are revoked.
curl -X DELETE https://api.zyphr.dev/v1/auth/users/me \
-H "X-Application-Key: za_live_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
const response = await fetch('https://api.zyphr.dev/v1/auth/users/me', {
method: 'DELETE',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
},
});
| Status | Code | Cause |
|---|---|---|
| 200 | — | Account deleted successfully |
| 404 | not_found | User not found |
| 410 | gone | Account already deleted |
Endpoint Reference
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/auth/password-requirements | Public key | Get password requirements |
POST | /v1/auth/users/register | App credentials | Register new user |
POST | /v1/auth/users/login | App credentials | Login with email/password |
POST | /v1/auth/sessions/refresh | App credentials | Refresh access token |
POST | /v1/auth/sessions/revoke | App credentials | Revoke session (logout) |
POST | /v1/auth/sessions/revoke-all | App credentials + user token | Revoke all sessions |
GET | /v1/auth/sessions | Public key + user token | List active sessions |
POST | /v1/auth/users/change-password | Public key + user token | Change password (in-session; knows current password) |
POST | /v1/auth/forgot-password | App credentials | Email a password reset link |
POST | /v1/auth/reset-password | App credentials | Consume the emailed token, set a new password |
POST | /v1/auth/verify-email/send | App credentials + user token | Send a verification email (accepts + suppresses if not allowlisted) |
POST | /v1/auth/verify-email/confirm | App credentials | Confirm a token you collected yourself (idempotent) |
POST | /v1/auth/verify-email/resend | App credentials + user token | Resend the verification email (rate-limited 60s) |
GET | /v1/auth/users/me | Public key + user token | Get current user |
PATCH | /v1/auth/users/me | Public key + user token | Update profile |
DELETE | /v1/auth/users/me | Public key + user token | Delete account |