Managing End Users
Once users exist in your application, you'll want to administer them from your
backend: list and search the directory, look users up, update or remove them,
pre-provision accounts, and attach trusted authorization data. These are
secret-key operations — they authenticate with your application's public key
and secret key (X-Application-Key + X-Application-Secret) and must run
server-side, never in a client bundle.
import { Zyphr } from '@zyphr-dev/node-sdk';
const zyphr = new Zyphr({
apiKey: process.env.ZYPHR_API_KEY, // zy_live_* / zy_test_*
applicationKey: process.env.ZYPHR_APP_KEY, // za_pub_*
applicationSecret: process.env.ZYPHR_APP_SECRET, // za_sec_* — server-side only
});
All operations below are scoped to the application whose credentials you present. A call can only ever read or modify users in your application — there is no cross-application access.
The user directory
List and search
const { data, meta } = await zyphr.auth.userDirectory.listEndUsers(
1, // page (1-based)
20, // perPage (max 100)
'jane', // search — substring match against email, name, or phone
'live', // environment — 'test' | 'live' (optional)
);
Pagination is returned in meta (page, per_page, total, total_pages).
Get a single user
// By id
const user = await zyphr.auth.userDirectory.getEndUserById(userId);
// By email (the password hash is never returned)
const byEmail = await zyphr.auth.userDirectory.getEndUserByEmail('jane@example.com');
Update a user
Updatable fields: email, name, avatarUrl, metadata, status
(active or suspended).
await zyphr.auth.userDirectory.updateEndUserByApplication(userId, {
name: 'Jane Doe',
status: 'suspended',
});
metadata is not for authorizationThe metadata field is writable by the end user themselves (via their own
token), so it is not trusted. Never use it for authorization decisions such
as an admin flag — use custom claims
for that.
To change a user's email, the new address must be unique within the environment;
a collision returns 409 conflict.
Delete a user
await zyphr.auth.userDirectory.deleteEndUserByApplication(userId);
This is a soft-delete: it revokes all of the user's sessions and emits a
user.deleted webhook. The status field cannot be set to deleted via
update — use this endpoint so the session-revocation and webhook fire.
Delete lifecycle (reconciling a local mirror)
If you keep a local mirror of users and reconcile against Zyphr, the application-scoped admin delete has a specific status-code lifecycle:
DELETE /auth/users/:id(application key + secret) returns204 No Content.- A subsequent
GET /auth/users/:idreturns404 not_found— the row is soft-deleted (status = 'deleted') and no longer readable. - The user drops out of
listEndUsersand out of user counts.
So the correct reconciliation signal is a 204 on delete followed by a 404 on
read. Repeat deletes return 410 gone ("already deleted").
The 204 is specific to the application-scoped admin delete
(DELETE /auth/users/:id with X-Application-Secret). Two other delete paths
return a JSON body with 200:
- Dashboard JWT delete →
200 { "deleted": true } - Self-service
DELETE /auth/users/me(end-user token) →200 { "success": true }
All three are soft deletes; only the response envelope differs.
Pre-provisioning and inviting users
Rather than asking everyone to self-register, you can pre-create users and send them a first-login link. This is ideal for onboarding a known set of people (staff, testers, an imported list).
await zyphr.auth.userDirectory.inviteEndUser({
email: 'newuser@example.com',
name: 'New User',
metadata: { source: 'import' },
sendLink: 'magic', // 'magic' | 'password_reset' | 'none'
redirectUrl: 'yourapp://verify_email', // required when sendLink is set
});
- The user is created passwordless and is idempotent on email — inviting an existing (non-deleted) user reuses that user rather than duplicating.
sendLink: 'magic'sends a magic-link sign-in email;'password_reset'sends a set-password email;'none'creates the user without sending anything.redirectUrlis where the link lands. Custom URL schemes are supported (e.g.yourapp://verify_email), so mobile deep links work — the token is appended as a query parameter (yourapp://verify_email?token=...).
The response indicates whether the user was newly created and whether a
link_sent succeeded.
To pre-provision a whole list, call this once per user. There is no bulk endpoint today; loop over your list server-side.
Trusted custom claims for authorization
The custom_claims you can pass on login are per-issuance — you'd have to
re-supply them on every login, and they aren't remembered. For anything you use
in authorization decisions (an account type, an admin flag), use the
persistent claims store instead.
Stored claims are server-set only — clients cannot write them, which is what makes them safe as an authorization signal. They are embedded automatically into the user's JWTs on every login and refresh.
Set and read claims
// Set (upsert). Max 4KB; reserved JWT/Zyphr claim names are rejected.
const { data } = await zyphr.auth.userDirectory.setEndUserClaims(userId, {
claims: { account_type: 'admin', tier: 'gold' },
});
// data.claims_version — a monotonic version, bumped on each write
// Read
const current = await zyphr.auth.userDirectory.getEndUserClaims(userId);
Verify these claims from the JWT (via your JWKS setup) or trust them because they
came from a token Zyphr signed. Do not read authorization data from the
client-writable metadata field.
Promotion and demotion — seamlessly
Because stored claims are embedded on every token issuance, a change takes effect on the user's next login or token refresh, with no action needed from them:
- Promote / change a role: call
setEndUserClaimswith the new claims. The user's next refresh silently re-mints their token with the updated claims. - Demote: call
setEndUserClaimswith the reduced claims. Each write bumpsclaims_version; a token still carrying the older version is rejected on verification, which forces the client to refresh — and the refresh re-mints with the new (lower) claims. No forced logout.
Immediate hard cut-off
If you need to end a user's access right now (not on their next refresh), call:
await zyphr.auth.sessions.revokeAllEndUserSessions(/* user's token */);
revoke-all invalidates all of the user's outstanding tokens immediately and
logs them out everywhere. Use the seamless demote for routine role changes, and
revoke-all for security-sensitive de-provisioning.
Fail-closed for admin gates
By default the revocation check fails open: if the revocation store is
briefly unreachable, a signature-valid token is still accepted (availability over
strict revocation — the right default for general auth). If you use claims as an
admin authorization gate, you can flip this per application so the check
fails closed — a token is rejected when the store can't be reached — by
enabling revocation_fail_closed on the application (configured in the
dashboard / application settings). This is the correct tradeoff for an admin
privilege gate, at the cost of rejecting tokens during a store outage.
Configuring email-OTP length and expiry
Email-OTP code length and expiry are configurable per application:
- Length: 4–8 digits (default 6)
- Expiry: 1–60 minutes (default 5)
Set these in your application settings so the OTP behavior matches your existing flows (for example, a 6-digit code with a 15-minute window).
Related
- Adding Auth to Your App — the getting-started guide.
- End User Authentication — the auth flows.
- Client-side Auth — calling auth from a browser or React Native app with the publishable key.