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 |
Server-side endpoints (register, login) require both the public key and secret key.
Refresh (/auth/sessions/refresh) needs only the public key — the refresh
token is the credential, so it is safe for client-side use. See
Refresh Token.
User-authenticated endpoints (profile, sessions) require the public key plus the user's access token. No secret key needed — safe for client-side use.
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 |
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 |
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 |