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.

Verifying tokens & JWKS​

End-user access tokens are JWTs. How you verify them — and which claims you can rely on — depends on your application's signing algorithm.

JWKS presence does not mean your tokens are RS256​

JWKS contents do not tell you your application's signing algorithm

https://api.zyphr.dev/.well-known/jwks.json serves the shared platform RS256 public key — it is a platform-wide key, not per-application. When the platform has an asymmetric keypair configured, the endpoint returns that one shared key; when it does not, it returns an empty key set ({ "keys": [] }) rather than an error.

Either way, the JWKS tells you nothing about your own application's algorithm:

  • A non-empty JWKS does not mean your application issues asymmetric (RS256) tokens — the key is shared platform-wide, and your app may still be on the HS256 default.
  • An empty JWKS means the platform has no asymmetric keypair configured; it does not describe your application either.

To confirm your signing algorithm, do one of:

Never infer the algorithm from JWKS presence (or absence).

Applications default to HS256 (a symmetric secret). RS256/ES256 are opt-in — they must be enabled per application, and only then do your tokens verify offline against the JWKS. On HS256, Zyphr verifies tokens for you at the API boundary; there is no public key to verify against offline.

Claim set varies by signing algorithm​

The end-user token claim set differs by algorithm — do not hard-code assumptions about which claims are present.

ClaimHS256 (default)RS256 / ES256
application_id✅✅
email✅✅
type (access / refresh)✅✅
iat✅✅
exp✅✅
aud"zyphr:enduser"your application_id
iss❌ (omitted)✅ (https://api.zyphr.dev)
sub❌ (omitted)✅ (= user ID)
sid✅✅ (the login session; stays the same across refreshes)

Key differences:

  • HS256 omits iss and sub. This keeps the wire format byte-identical to the original release. A verifier that requires iss/sub will reject valid HS256 tokens — don't assume they are present.
  • RS256/ES256 add iss and sub (sub is the RFC 7519 subject = user ID), so off-the-shelf JWKS verifiers that key off sub work out of the box.
  • aud differs. HS256 tokens carry the global aud: "zyphr:enduser". Asymmetric tokens set aud to your application_id, which pins the token to exactly one application and makes offline JWKS verification tenant-safe by default.
Configuring an offline JWKS verifier for RS256/ES256

Set your verifier's expected audience to your application_id (not the old global zyphr:enduser), and its issuer to https://api.zyphr.dev. Fetch the public key from /.well-known/jwks.json, matching the token's kid header.

Revocation and the access-token lifetime​

Zyphr's own token verification rejects an access token as soon as:

  • its session is revoked: logout, revoking that session, or eviction by revoke_oldest. That covers every access token the login issued, including ones minted before its most recent refresh. Only that session's tokens are affected; the user's other sessions keep working.
  • all of the user's sessions are revoked: revoke-all, or the user is suspended.
  • its custom claims are out of date: the stored claims changed after it was minted, so the client refreshes to pick them up.

Refreshing a token does not revoke the previous access token. Requests already in flight with it keep working until it expires.

Offline verifiers can't see revocations

If you verify tokens offline against /.well-known/jwks.json, you only see the signature and exp. A revoked session's access token stays valid to you until it expires (the application's session duration, 24 hours by default). If you need immediate revocation, keep the access-token lifetime short. You can also maintain your own denylist keyed on the sid claim, which names the login session and stays the same across refreshes.

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
200—Verification 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
200—Verification 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
200—Password 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 account admins to impersonate end users for debugging and support. Every impersonation session is audited: who started it, for which user, why, from which IP, when it expired or was ended, and a user.impersonated webhook fires on start. Individual requests made with the impersonation token are not logged separately — they look like the end user's own requests to your application. Ending a session revokes its token immediately.

caution

Impersonation tokens are access-only (no refresh token) and expire after a configurable duration (1–60 minutes, default 30). 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 30)

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. The session start and end are audited. 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 account 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

Customizing Auth Emails​

The magic link, password reset, and email verification emails can be fully customized per application. By default, Zyphr ships system templates that interpolate your project brand (primary/secondary color, font family, logo URL) so a tenant who only sets brand defaults gets a branded experience without authoring full overrides.

For full control — custom HTML/MJML, subject, From display name, Reply-To — use the Auth Email Templates API.

Hierarchy​

Project (brand defaults)
└─ Application (per-application template overrides)
└─ email_type ∈ { magic_link, password_reset, email_verification }

When an email is sent:

  1. The application's override row for the email type is loaded if present.
  2. If is_enabled = false or no override exists, the system default is used.
  3. In either case, the project's brand_* settings are merged into the Handlebars context as {{brand_primary_color}}, {{brand_secondary_color}}, {{brand_font_family}}, {{brand_logo_url}}.

Variable contracts​

Email typeVariables
magic_linkapplication_name, magic_link_url, expires_minutes, user_email, user_name, brand_*
password_resetapplication_name, reset_url, expires_minutes, user_email, user_name, brand_*
email_verificationapplication_name, verify_url, expires_hours, user_email, user_name, brand_*

Templates reference these as Handlebars expressions ({{variable_name}}). Unknown variables log a warning on save and render as empty strings.

API​

MethodEndpointAuthDescription
GET/v1/auth/email-templatesApp credentialsList effective templates per type (override or default)
GET/v1/auth/email-templates/{type}App credentialsGet a single template
GET/v1/auth/email-templates/{type}/defaultApp credentialsFetch the un-rendered system default for forking
PUT/v1/auth/email-templates/{type}App credentialsCreate or update an override
PUT/v1/auth/email-templatesApp credentialsBulk-upsert any subset of types (atomic)
DELETE/v1/auth/email-templates/{type}App credentialsRemove the override and revert to default
POST/v1/auth/email-templates/{type}/previewApp credentialsRender with sample/provided variables (no send)
POST/v1/auth/email-templates/{type}/testApp credentialsSend a test render (recipient must be in the app's test_recipients allowlist)
GET/v1/auth/email-templates/{type}/versionsApp credentialsList version history
POST/v1/auth/email-templates/{type}/versions/{version}/restoreApp credentialsRestore a previous version

Dashboard JWT-authenticated equivalents live under /v1/auth/dashboard/applications/:id/email-templates/... for use by your own dashboard UI.

Editing format​

A template can be saved as either:

  • Plain HTML + text (html + text fields): Handlebars-templated.
  • MJML source + text (mjml_source + text fields): MJML is compiled to HTML server-side on save. Compile errors return 400 mjml_compile_failed with a structured details array — useful for migration scripts to surface fixable issues to the user.

Test send allowlist​

POST /v1/auth/email-templates/{type}/test will only deliver to addresses in the application's test_recipients array. Empty allowlist (the default) blocks all test sends via the public API. The dashboard JWT flow additionally allows the authenticated user's own email.

Set the allowlist by any of:

  • PATCH /v1/auth/application with a test_recipients field — application-secret scoped, so you can manage it from your own infrastructure config / CI. GET /v1/auth/application returns the current value. See Setting the auth-email allowlist from config.
  • PATCH /v1/applications/:id with a test_recipients field — dashboard JWT scoped (used by our own dashboard UI).

Project brand​

Brand defaults live on the project (not the application — multiple apps in one project share branding) and are settable via the dashboard's Project Settings → Brand tab or the API:

MethodEndpointAuth
GET/v1/projects/:id/brandDashboard JWT
PATCH/v1/projects/:id/brandDashboard JWT (account owner/admin or project admin)

Color fields validate against ^#[0-9A-Fa-f]{6}$.