Skip to main content

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

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 authorization

The 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:

  1. DELETE /auth/users/:id (application key + secret) returns 204 No Content.
  2. A subsequent GET /auth/users/:id returns 404 not_found — the row is soft-deleted (status = 'deleted') and no longer readable.
  3. The user drops out of listEndUsers and 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").

Which delete returns what

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.
  • redirectUrl is 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 setEndUserClaims with the new claims. The user's next refresh silently re-mints their token with the updated claims.
  • Demote: call setEndUserClaims with the reduced claims. Each write bumps claims_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).