Skip to main content

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" }'
Node.js
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',
}),
});
ParameterTypeRequiredDescription
redirect_urlstringNoURL to redirect to after clicking the link. Must match a configured redirect_uri.
StatusCodeCause
200Verification email sent
400already_verifiedEmail is already verified
400validation_errorRedirect 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" }'
Node.js
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" }'
Node.js
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',
}),
});
StatusCodeCause
200Verification email resent
400already_verifiedEmail is already verified
429rate_limitedMust 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"
}'
Node.js
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',
}),
});
ParameterTypeRequiredDescription
emailstringYesUser's email address
redirect_urlstringNoMust 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" }'
Node.js
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"
}'
Node.js
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',
}),
});
ParameterTypeRequiredDescription
tokenstringYesReset token from the email
new_passwordstringYesMust meet the application's password requirements
StatusCodeCause
200Password reset successfully
400invalid_tokenToken is invalid or expired
400password_validation_errorNew 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"
Node.js
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"
Node.js
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"
Node.js
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.

caution

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
}'
Node.js
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,
}),
}
);
ParameterTypeRequiredDescription
reasonstringNoAudit trail reason for impersonation
duration_minutesnumberNoSession 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"
Node.js
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"
Node.js
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"
Node.js
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"
Node.js
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" }'
Node.js
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" }
}'
Node.js
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)
ParameterTypeRequiredDescription
phone_numberstringYesE.164 format (e.g., +15551234567)
codestringYesOTP code from SMS
namestringNoDisplay name
metadataobjectNoArbitrary 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" }'
Node.js
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"
}'
Node.js
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

StatusCodeCause
400phone_not_foundNo account with this phone number (login only)
400invalid_otpIncorrect or expired OTP code
429sms_rate_limitToo many SMS requests. Includes retry_after in meta.
503sms_not_configuredTwilio 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" }'
Node.js
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

Browser
// 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))),
},
},
}),
});
Backend verification call
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" }'
Node.js
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..." }
}'
Node.js
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"
Node.js
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

MethodEndpointAuthDescription
POST/v1/auth/verify-email/sendApp credentials + user tokenSend verification email
POST/v1/auth/verify-email/confirmApp credentialsConfirm with token
POST/v1/auth/verify-email/resendApp credentials + user tokenResend verification

Password Reset

MethodEndpointAuthDescription
POST/v1/auth/forgot-passwordApp credentialsRequest reset email
POST/v1/auth/validate-reset-tokenApp credentialsValidate token
POST/v1/auth/reset-passwordApp credentialsConfirm password reset

Account Lockouts

MethodEndpointAuthDescription
GET/v1/applications/:id/lockoutsDashboard JWTList locked accounts
GET/v1/applications/:id/lockouts/:emailDashboard JWTGet lockout details
POST/v1/applications/:id/lockouts/:email/unlockDashboard JWTUnlock account

User Impersonation

MethodEndpointAuthDescription
POST/v1/applications/:id/users/:userId/impersonateDashboard JWT (owner/admin)Start impersonation
DELETE/v1/applications/:id/impersonation/:impersonationIdDashboard JWT (owner/admin)End impersonation
GET/v1/applications/:id/impersonationDashboard JWT (owner/admin)List active impersonations
GET/v1/applications/:id/users/:userId/impersonation-historyDashboard JWT (owner/admin)Get audit history

Phone Authentication

MethodEndpointAuthDescription
GET/v1/auth/phone/availablePublic keyCheck SMS availability
POST/v1/auth/phone/register/sendApp credentialsSend registration OTP
POST/v1/auth/phone/register/verifyApp credentialsVerify OTP + create account
POST/v1/auth/phone/login/sendApp credentialsSend login OTP
POST/v1/auth/phone/login/verifyApp credentialsVerify OTP + login

WebAuthn / Passkeys

MethodEndpointAuthDescription
POST/v1/auth/webauthn/registration/startApp credentials + user tokenStart credential registration
POST/v1/auth/webauthn/registration/verifyApp credentials + user tokenComplete registration
POST/v1/auth/webauthn/authentication/startApp credentialsStart authentication
POST/v1/auth/webauthn/authentication/verifyApp credentialsComplete authentication
GET/v1/auth/webauthn/credentialsPublic key + user tokenList credentials
DELETE/v1/auth/webauthn/credentials/:idPublic key + user tokenDelete credential
PATCH/v1/auth/webauthn/credentials/:idPublic key + user tokenRename credential
GET/v1/auth/webauthn/statusPublic key + user tokenCheck WebAuthn status