Security Features
Advanced security features for your Auth-as-a-Service applications, including password policies, email verification, password reset, account lockouts, user impersonation, phone authentication, and WebAuthn/passkeys.
Email Verification
Verify that users own the email addresses they registered with.
Send Verification Email
Requires the user's access token:
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "redirect_url": "https://myapp.com/verify-email" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/verify-email/send', {
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',
},
body: JSON.stringify({
redirect_url: 'https://myapp.com/verify-email',
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
redirect_url | string | No | URL to redirect to after clicking the link. Must match a configured redirect_uri. |
| Status | Code | Cause |
|---|---|---|
| 200 | — | Verification email sent |
| 400 | already_verified | Email is already verified |
| 400 | validation_error | Redirect URL not in allowed list |
Confirm Verification
When the user clicks the verification link, extract the token and confirm:
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/confirm \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "token": "evt_xxxx" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/verify-email/confirm', {
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({ token: verificationToken }),
});
// data.email — the verified email address
Resend Verification
Rate-limited to once per 60 seconds:
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/resend \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "redirect_url": "https://myapp.com/verify-email" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/verify-email/resend', {
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',
},
body: JSON.stringify({
redirect_url: 'https://myapp.com/verify-email',
}),
});
| Status | Code | Cause |
|---|---|---|
| 200 | — | Verification email resent |
| 400 | already_verified | Email is already verified |
| 429 | rate_limited | Must wait before resending |
Password Reset
Three-step flow: request reset, validate token (optional), confirm reset.
Request Password Reset
curl -X POST https://api.zyphr.dev/v1/auth/forgot-password \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"redirect_url": "https://myapp.com/reset-password"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/forgot-password', {
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',
redirect_url: 'https://myapp.com/reset-password',
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | User's email address |
redirect_url | string | No | Must match a configured redirect_uri |
Always returns success to prevent email enumeration, regardless of whether the email exists.
Validate Reset Token
Check if a token is valid before showing the reset form. Does not consume the token:
curl -X POST https://api.zyphr.dev/v1/auth/validate-reset-token \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "token": "prt_xxxx" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/validate-reset-token', {
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({ token: resetToken }),
});
// data.valid — true if token is valid
// data.email — the email associated with the token
Confirm Password Reset
curl -X POST https://api.zyphr.dev/v1/auth/reset-password \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"token": "prt_xxxx",
"new_password": "NewSecureP@ss456"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/reset-password', {
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({
token: resetToken,
new_password: 'NewSecureP@ss456',
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Reset token from the email |
new_password | string | Yes | Must meet the application's password requirements |
| Status | Code | Cause |
|---|---|---|
| 200 | — | Password reset successfully |
| 400 | invalid_token | Token is invalid or expired |
| 400 | password_validation_error | New password doesn't meet requirements (includes errors and requirements) |
Account Lockouts
Automatic account lockout after too many failed login attempts. Managed through the dashboard API (JWT auth).
List Locked Accounts
curl "https://api.zyphr.dev/v1/applications/app_abc123/lockouts?limit=50&offset=0" \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/lockouts?limit=50&offset=0',
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
// data.accounts — array of locked accounts
// data.total — total count
Get Lockout Details
Get the full lockout status, history, and recent failed attempts for a specific email:
curl https://api.zyphr.dev/v1/applications/app_abc123/lockouts/jane@example.com \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
`https://api.zyphr.dev/v1/applications/app_abc123/lockouts/${encodeURIComponent(email)}`,
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
Response
{
"data": {
"email": "jane@example.com",
"current_status": {
"locked": true,
"locked_until": "2025-01-15T11:00:00Z",
"attempt_count": 5
},
"lockout_history": [
{
"id": "lock_123",
"locked_at": "2025-01-15T10:30:00Z",
"locked_until": "2025-01-15T11:00:00Z",
"attempt_count": 5,
"unlocked_at": null,
"unlocked_by": null,
"unlock_reason": null
}
],
"recent_failed_attempts": [
{
"id": "att_456",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0...",
"failure_reason": "invalid_password",
"created_at": "2025-01-15T10:29:00Z"
}
]
}
}
Unlock Account
Manually unlock a locked account:
curl -X POST https://api.zyphr.dev/v1/applications/app_abc123/lockouts/jane@example.com/unlock \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
`https://api.zyphr.dev/v1/applications/app_abc123/lockouts/${encodeURIComponent(email)}/unlock`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
User Impersonation
Allow workspace admins to impersonate end users for debugging and support. All impersonation sessions are fully audited — every action is logged with the admin's identity.
Impersonation tokens are access-only (no refresh token) and expire after a configurable duration (1–60 minutes, default 15). Requires owner or admin role.
Start Impersonation
curl -X POST https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonate \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{
"reason": "Investigating billing display issue reported in ticket #1234",
"duration_minutes": 15
}'
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonate',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${dashboardToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
reason: 'Investigating billing display issue reported in ticket #1234',
duration_minutes: 15,
}),
}
);
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | string | No | Audit trail reason for impersonation |
duration_minutes | number | No | Session duration (1–60 min, default 15) |
Response
{
"data": {
"tokens": {
"access_token": "eyJhbGci...",
"expires_in": 900,
"token_type": "Bearer"
},
"session": {
"id": "imp_abc123",
"admin_email": "admin@company.com",
"end_user_email": "jane@example.com",
"reason": "Investigating billing display issue",
"started_at": "2025-01-15T10:00:00Z",
"expires_at": "2025-01-15T10:15:00Z"
},
"user": {
"id": "usr_xyz",
"email": "jane@example.com",
"name": "Jane Doe"
}
},
"meta": {
"warning": "This is an impersonation token. All actions taken will be logged. No refresh token is provided - you must re-authenticate after expiration."
}
}
End Impersonation
Manually end an impersonation session before it expires:
curl -X DELETE https://api.zyphr.dev/v1/applications/app_abc123/impersonation/imp_abc123 \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/impersonation/imp_abc123',
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
List Active Impersonations
curl https://api.zyphr.dev/v1/applications/app_abc123/impersonation \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/impersonation',
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
Get Impersonation History
View the full audit trail for a specific user:
curl "https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonation-history?limit=50&offset=0" \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
`https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonation-history?limit=50`,
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
Returns sessions with admin_email, reason, ip_address, user_agent, started_at, ended_at, and end_reason.
Phone Authentication
SMS OTP-based registration and login. Requires BYOP (Bring Your Own Provider) Twilio credentials configured in workspace settings.
Check Availability
curl https://api.zyphr.dev/v1/auth/phone/available \
-H "X-Application-Key: za_pub_xxxx"
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/available', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
},
});
// data.available — true if SMS is configured
Registration Flow
Step 1: Send OTP
curl -X POST https://api.zyphr.dev/v1/auth/phone/register/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "phone_number": "+15551234567" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/register/send', {
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({ phone_number: '+15551234567' }),
});
// data.expires_in — OTP expiration in seconds
Step 2: Verify OTP and create account
curl -X POST https://api.zyphr.dev/v1/auth/phone/register/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+15551234567",
"code": "123456",
"name": "Jane Doe",
"metadata": { "source": "mobile-app" }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/register/verify', {
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({
phone_number: '+15551234567',
code: '123456',
name: 'Jane Doe',
metadata: { source: 'mobile-app' },
}),
});
// Returns user + tokens (same as email registration)
| Parameter | Type | Required | Description |
|---|---|---|---|
phone_number | string | Yes | E.164 format (e.g., +15551234567) |
code | string | Yes | OTP code from SMS |
name | string | No | Display name |
metadata | object | No | Arbitrary user metadata |
Login Flow
Step 1: Send OTP
curl -X POST https://api.zyphr.dev/v1/auth/phone/login/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "phone_number": "+15551234567" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/login/send', {
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({ phone_number: '+15551234567' }),
});
Step 2: Verify OTP and login
curl -X POST https://api.zyphr.dev/v1/auth/phone/login/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+15551234567",
"code": "123456"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/login/verify', {
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({
phone_number: '+15551234567',
code: '123456',
}),
});
Phone login also supports custom_claims and returns MFA challenges if MFA is enabled — same as email login.
Error Responses
| Status | Code | Cause |
|---|---|---|
| 400 | phone_not_found | No account with this phone number (login only) |
| 400 | invalid_otp | Incorrect or expired OTP code |
| 429 | sms_rate_limit | Too many SMS requests. Includes retry_after in meta. |
| 503 | sms_not_configured | Twilio credentials not configured |
WebAuthn / Passkeys
FIDO2/WebAuthn support for passwordless authentication using biometrics, hardware keys, or platform authenticators. WebAuthn provides phishing-resistant authentication with no shared secrets.
Registration Flow
Users must be authenticated to register a new credential. The flow uses the Web Authentication API (navigator.credentials.create()).
Step 1: Get registration options
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/registration/start \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "credential_name": "MacBook Pro Touch ID" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/registration/start', {
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',
},
body: JSON.stringify({ credential_name: 'MacBook Pro Touch ID' }),
});
// data.challenge_id — needed for verification step
// data.options — pass to navigator.credentials.create()
Step 2: Create credential in browser and verify
// Use the options from Step 1
const credential = await navigator.credentials.create({
publicKey: data.options,
});
// Send the response to your backend for verification
const verifyResponse = await fetch('/api/auth/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge_id: data.challenge_id,
response: {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
type: credential.type,
response: {
attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
},
},
}),
});
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/registration/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "wac_xxxx",
"response": { "...WebAuthn response object..." },
"credential_name": "MacBook Pro Touch ID"
}'
Authentication Flow
Step 1: Get authentication options
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/authentication/start \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "user_id": "usr_abc123" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/authentication/start', {
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({
user_id: userId, // optional — omit for discoverable credential flow
}),
});
// data.challenge_id + data.options — pass options to navigator.credentials.get()
The user_id parameter is optional. Omit it for discoverable credential (passkey) flows where the authenticator identifies the user.
Step 2: Authenticate and verify
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/authentication/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "wac_xxxx",
"response": { "...WebAuthn assertion response..." }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/authentication/verify', {
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({
challenge_id: challengeId,
response: webauthnResponse,
}),
});
// Returns user + tokens (same as email login)
// Also supports MFA challenge if MFA is enabled
Authentication verify also supports custom_claims for embedding data in the JWT.
Credential Management
List credentials:
curl https://api.zyphr.dev/v1/auth/webauthn/credentials \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/credentials', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
},
});
// data.credentials — array of { id, name, transports, is_discoverable, is_backed_up, last_used_at, created_at }
Rename credential:
curl -X PATCH https://api.zyphr.dev/v1/auth/webauthn/credentials/cred_123 \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Work Laptop" }'
Delete credential:
curl -X DELETE https://api.zyphr.dev/v1/auth/webauthn/credentials/cred_123 \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
Check WebAuthn status:
curl https://api.zyphr.dev/v1/auth/webauthn/status \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
{
"data": {
"enabled": true,
"credential_count": 2
}
}
Endpoint Reference
Email Verification
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/auth/verify-email/send | App credentials + user token | Send verification email |
POST | /v1/auth/verify-email/confirm | App credentials | Confirm with token |
POST | /v1/auth/verify-email/resend | App credentials + user token | Resend verification |
Password Reset
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/auth/forgot-password | App credentials | Request reset email |
POST | /v1/auth/validate-reset-token | App credentials | Validate token |
POST | /v1/auth/reset-password | App credentials | Confirm password reset |
Account Lockouts
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/applications/:id/lockouts | Dashboard JWT | List locked accounts |
GET | /v1/applications/:id/lockouts/:email | Dashboard JWT | Get lockout details |
POST | /v1/applications/:id/lockouts/:email/unlock | Dashboard JWT | Unlock account |
User Impersonation
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/applications/:id/users/:userId/impersonate | Dashboard JWT (owner/admin) | Start impersonation |
DELETE | /v1/applications/:id/impersonation/:impersonationId | Dashboard JWT (owner/admin) | End impersonation |
GET | /v1/applications/:id/impersonation | Dashboard JWT (owner/admin) | List active impersonations |
GET | /v1/applications/:id/users/:userId/impersonation-history | Dashboard JWT (owner/admin) | Get audit history |
Phone Authentication
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/auth/phone/available | Public key | Check SMS availability |
POST | /v1/auth/phone/register/send | App credentials | Send registration OTP |
POST | /v1/auth/phone/register/verify | App credentials | Verify OTP + create account |
POST | /v1/auth/phone/login/send | App credentials | Send login OTP |
POST | /v1/auth/phone/login/verify | App credentials | Verify OTP + login |
WebAuthn / Passkeys
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/auth/webauthn/registration/start | App credentials + user token | Start credential registration |
POST | /v1/auth/webauthn/registration/verify | App credentials + user token | Complete registration |
POST | /v1/auth/webauthn/authentication/start | App credentials | Start authentication |
POST | /v1/auth/webauthn/authentication/verify | App credentials | Complete authentication |
GET | /v1/auth/webauthn/credentials | Public key + user token | List credentials |
DELETE | /v1/auth/webauthn/credentials/:id | Public key + user token | Delete credential |
PATCH | /v1/auth/webauthn/credentials/:id | Public key + user token | Rename credential |
GET | /v1/auth/webauthn/status | Public key + user token | Check WebAuthn status |