Concepts
Modelling your users
Separating your staff from your customers, and giving people types like seller, supplier or admin.
Most applications have more than one kind of person in them: customers and staff, or buyers and sellers, or clients and suppliers. This page is about which of those differences belongs in a separate user pool and which belongs inside one.
Two questions decide it:
- Should these people be able to sign in to the same place? If
your staff sign in at
admin.yourapp.comand your customers atyourapp.com, and no account should ever work on both, they are different pools. - Do they belong to companies? If a person acts on behalf of an organisation with its own members and its own permissions, that is what organizations are for.
Your staff: a separate project
Use a second project for your internal admin or back office.
A project’s environment is a hard tenant boundary. Separate user pool, separate keys, separate issuer, separate JWKS. An account in one cannot sign in to the other, and a token minted for one fails verification against the other, not by policy but because the signature does not check out.
yourapp → your customers
yourapp-admin → your staff
That is the strongest separation we offer, and it is the right default for staff. It means a bug in your customer sign-up flow cannot create a staff account, and a leaked customer key does nothing to your admin tools.
Inside the admin project, use publicMetadata for seniority:
await zevauth.users.update('usr_...', {
publicMetadata: { role: 'support', canRefund: false },
});
Kinds of user in one pool: publicMetadata
When everybody signs in at the same place but behaves differently (buyers and sellers on a marketplace, clients and suppliers on a procurement tool), keep one pool and give people a type.
await zevauth.users.update('usr_...', {
publicMetadata: { type: 'seller', verifiedAt: '2026-01-01' },
});
publicMetadata travels inside the access token, so your backend
reads it from the verified JWT with no call to us:
{
"sub": "usr_...",
"iss": "https://api.zevauth.net/v1/env_live_...",
"public_metadata": { "type": "seller" }
}
const { payload } = await jwtVerify(token, jwks, { issuer, audience });
if (payload.public_metadata?.type !== 'seller') return forbidden();
Why this is safe to trust
publicMetadata is writable only with a secret key. The end user’s
own client cannot change it: there is no field for it on PATCH /v1/me,
by design. So a value you set on your server is a value only your server
set.
The three scopes exist precisely so this question has an answer:
| Scope | Who can read | Who can write | Use for |
|---|---|---|---|
publicMetadata | The user’s client, and your backend, via the token | Your backend only | Types, roles, entitlements: anything an authorization decision reads |
privateMetadata | Your backend only | Your backend only | Internal references, a Stripe customer id, risk flags |
unsafeMetadata | Anyone with the token | The user’s own client | Preferences. Theme, language, a dismissed banner |
Fetching one kind of user
publicMetadata is filterable, so a type you set is a type you can list:
const sellers = await zevauth.users.list({ metadata: { type: 'seller' } });
GET /v1/users?metadata[type]=seller
Keys combine with AND, and totalCount reflects the filter rather than
your whole userbase. For a list you need constantly, keep your own table
keyed by our user id and maintain it from
webhooks instead of polling this.
People who belong to companies: organizations
If a seller is really a company with several staff, and those staff need different powers, that is an organization rather than a type.
await zevauth.organizations.create({ name: 'Acme Supplies', createdBy: userId });
The session then carries the organization and what that person may do inside it:
{
"org_id": "org_...",
"org_role": "org:admin",
"org_permissions": ["org:members:manage", "invoices:approve"]
}
You define your own roles and permissions per environment in the
console, so supplier:admin with invoices:approve is yours to create.
See Organizations.
Choosing
| You want | Use |
|---|---|
| Staff who must never sign in as customers | A second project |
| Buyers and sellers signing in at one place | publicMetadata.type |
| Users acting for a company, with their own members | Organizations |
| A back-office rank: support, finance, superadmin | publicMetadata inside the admin project |
| A user preference | unsafeMetadata |
The mistake worth avoiding is using metadata where you needed a separate project. If an account should never be able to sign in to the other surface, a field on a shared user is one bug away from letting it.
A worked example
A marketplace with customers, sellers, and internal support staff:
Project market: everybody who shops or sells.
// A shopper. No type at all is fine; absent means customer.
await zevauth.users.create({ email: 'ada@example.com' });
// A seller, once they finish onboarding.
await zevauth.users.update(sellerId, {
publicMetadata: { type: 'seller' },
privateMetadata: { payoutAccount: 'acct_...' },
});
// A seller that is a business with staff.
await zevauth.organizations.create({ name: 'Acme', createdBy: sellerId });
Project market-admin: support and finance. Different keys,
different sign-in page, different tokens.
await zevauth.users.update(agentId, {
publicMetadata: { role: 'support' },
});
Your admin backend verifies against the admin project’s issuer, so a customer token presented to it fails on the signature. Nothing has to remember to check.
Updated at, Wednesday, October 21, 2026