---
title: Modelling your users
description: 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:

1. **Should these people be able to sign in to the same place?** If
   your staff sign in at `admin.yourapp.com` and your customers at
   `yourapp.com`, and no account should ever work on both, they are
   different pools.
2. **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.

> **Note:**
> Both projects live in the same workspace, so you manage them from one
> console and they bill together. Separation is at the token and data
> layer, not at the account layer.

Inside the admin project, use **`publicMetadata`** for seniority:

```ts
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.

```ts
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:

```json
{
  "sub": "usr_...",
  "iss": "https://api.zevauth.net/v1/env_live_...",
  "public_metadata": { "type": "seller" }
}
```

```ts
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 |

> **Warning:**
> Never make an authorization decision from `unsafeMetadata`. The name is
> deliberately unfriendly: the user's browser can write it, so
> `unsafeMetadata.role = "admin"` is a request, not a fact.

### Fetching one kind of user

`publicMetadata` is filterable, so a type you set is a type you can list:

```ts
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](/api/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.

```ts
await zevauth.organizations.create({ name: 'Acme Supplies', createdBy: userId });
```

The session then carries the organization and what that person may do
inside it:

```json
{
  "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](/sdk/organizations/).

> **Warning:**
> `org_permissions` in a token is a **snapshot** from when the token was
> minted. It is correct for deciding what to render and not enough for
> deciding what to allow: somebody demoted a minute ago still carries the
> old permissions until their next refresh. Re-read the membership before
> anything that changes data. Our own API does exactly this.

## 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.

```ts
// 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.

```ts
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.
