# ZevAuth developer documentation — full text bundle
> Every page on `docs.zevauth.com` concatenated into one file. Page boundaries are marked by `===` separators carrying the page title and canonical URL so an LLM can cite back to the source.
For a curated map without the full body text, see https://docs.zevauth.com/llms.txt. For a single page, append `.md` to its URL.
---
===
# Quickstart
> A working sign-in, in about five minutes.
Source: https://docs.zevauth.com/guide/quickstart
---
import Callout from '../../../components/Callout.astro';
This gets you a real sign-in screen backed by real sessions. It assumes React;
see [Next.js](/sdk/nextjs) if you are using the app router.
## 1. Create a project
In the [console](https://console.zevauth.com), create a project. You get a
development environment and a **publishable key** that looks like
`pk_test_…`.
## 2. Install
```bash
npm install @zevauth/react
```
## 3. Wrap your app
```tsx
import { ZevAuthProvider } from '@zevauth/react';
export function App() {
return (
);
}
```
## 4. Add a sign-in screen
```tsx
import { SignIn, SignedIn, SignedOut, UserButton } from '@zevauth/react';
function YourApp() {
return (
<>
>
);
}
```
That is a complete flow. `` renders whichever methods your environment
has enabled, styled with your brand.
While the session is still being restored, neither `` nor
`` renders anything. That is deliberate. Treating "we do not know
yet" as signed out makes every page reload flash your signed-out UI. Use
`` to show something during that moment.
## 5. Call your own API
```tsx
import { useAuth } from '@zevauth/react';
function SaveButton() {
const { getToken } = useAuth();
async function save() {
const token = await getToken();
await fetch('/api/notes', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ text: 'hello' }),
});
}
return ;
}
```
`getToken()` refreshes the token first if it is close to expiring, so you never
have to think about it.
## 6. Verify it on your backend
```ts
import { verifyZevAuthToken } from '@zevauth/nextjs/server';
const claims = await verifyZevAuthToken(token, {
environmentId: 'env_test_…',
});
// claims.sub is the user's id.
```
It is a standard JWT, so any library works. See
[Verifying tokens](/concepts/verifying-tokens) for other runtimes.
## What next
- Turn on more sign-in methods in the console. They appear in ``
automatically.
- [Set your brand](/concepts/branding) so the screens look like your product.
- [Add organizations](/sdk/organizations) if your product has teams.
===
# Keys and environments
> Which key goes where, and which one must never reach a browser.
Source: https://docs.zevauth.com/guide/keys
---
import Callout from '../../../components/Callout.astro';
Every environment has its own keys. They are not interchangeable, and the
difference matters.
## Publishable keys
`pk_test_…` and `pk_live_…`
These identify your environment. They are **not secret**. They ship inside
your JavaScript bundle and anyone can read them. That is fine, because a
publishable key on its own can do almost nothing: it can start a sign-in, and
that is the point.
What stops somebody using yours on a phishing site is **origin locking**. A
publishable key is accepted only from your project's verified domain, its
subdomains, or a callback URL you registered. A copy of your key on
`your-app.attacker.example` is refused.
```tsx
// Correct. This is meant to be public.
```
## Secret keys
`sk_test_…` and `sk_live_…`
These act as your whole account. They read any user, create organizations and
change anything. They belong on a server, in an environment variable, and
nowhere else.
Never put a secret key in a browser, a mobile app, or anything you ship to a
user's device. `createZevAuth()` refuses a key starting with `sk_` for exactly
this reason. The mistake would otherwise work perfectly until somebody read
your JavaScript.
## Session tokens
Not a key. A **session token** is the short-lived JWT that represents one
signed-in person, and it is what your own API should accept.
The three are used in different places:
| Credential | Lives | Answers |
| --- | --- | --- |
| Publishable key | Your frontend bundle | Which app is this? |
| Secret key | Your server, in an env var | Is this the developer? |
| Session token | Memory, in one browser | Which person is this? |
Endpoints under `/v1/me` take a session token and nothing else. Handing them a
publishable key is refused, with an error that says so. Otherwise any holder
of a public key could read anybody's profile.
## Rotating a key
You can rotate keys in the console. A rotated publishable key stops working
immediately, so deploy the new one first.
===
# Development and production
> Two environments, deliberately unlike each other.
Source: https://docs.zevauth.com/guide/environments
---
import Callout from '../../../components/Callout.astro';
Every project starts with a **development** environment. You create
**production** when you are ready to have real users.
They share nothing: separate users, separate keys, separate signing keys,
separate organizations. A user who signs up in development does not exist in
production.
## What development does differently
**Email is captured, not delivered.** Sign-up confirmations, magic links and
password resets are stored and shown in the console's Sandbox instead of being
sent. You can build and test a whole sign-up flow without an inbox, and without
emailing a real person by accident during a seeding script.
**Limits are lower.** Development caps the number of users, which keeps a
runaway test loop from filling a table you will never look at again.
**The ZevAuth badge always shows.** "Secured by ZevAuth" cannot be removed in
development on any plan, so you never design against a screen that production
will not give you.
Only production users count toward billing. A development environment full of
seed data costs you nothing. See [monthly active users](/api/) for how the
count works.
## Going live
Production requires a verified domain, because a publishable key is locked to
it. The console walks you through the DNS records and tells you what is
outstanding.
Once production exists, the two run side by side forever. You do not migrate
between them, and you do not promote one to the other.
===
# React
> Hooks, control components and the provider.
Source: https://docs.zevauth.com/sdk/react
---
import Callout from '../../../components/Callout.astro';
```bash
npm install @zevauth/react
```
Everything starts with the provider. It creates one client, loads the session,
and keeps React in step with it.
```tsx
import { ZevAuthProvider } from '@zevauth/react';
```
## useUser
```tsx
const { isLoaded, isSignedIn, user } = useUser();
```
Three states, not two. On the first render of a page there is no answer yet.
A session may be restorable from storage, and finding out takes a request.
```tsx
if (!isLoaded) return ;
if (!isSignedIn) return ;
return
Hello {user.firstName}
;
```
Do not collapse `isLoaded` into `isSignedIn`. Treating "not yet known" as
signed out makes every reload flash your signed-out UI before correcting
itself, which reads as being logged out.
## useAuth
The credential-shaped view: ids and a token, no profile. Use it where you only
need to attach a token, so the component does not re-render every time a
display name changes.
```tsx
const { userId, organizationId, getToken, signOut } = useAuth();
const token = await getToken();
```
`getToken()` refreshes first if the token is close to expiring. `getToken` and
`signOut` keep a stable identity across renders, so they are safe in a
dependency array.
## useOrganization and useOrganizationList
```tsx
const { organization } = useOrganization();
const { organizations, setActive } = useOrganizationList();
await setActive('org_...'); // null returns to personal scope
```
Switching rotates the session's tokens, because the organization is a claim
inside them. See [Organizations](/sdk/organizations).
Members are fetched only when you ask:
```tsx
const { members, isLoadingMembers } = useOrganization({ withMembers: true });
```
## Control components
```tsx
…only when signed in… …only when signed out… …while the session resolves…
```
And `` for role and permission checks:
```tsx
Ask an admin.
}>
```
`` hides UI. It does not protect data. Everything it hides is still in
your JavaScript bundle, and a determined person will call your API directly.
The same check has to exist on your server, against the verified token.
## Errors
Anything the SDK throws is a `ZevAuthError` with a `code` you can branch on and
a `message` written for a person to read.
```tsx
try {
await client.signIn.withPassword({ identifier, password });
} catch (err) {
if (err instanceof ZevAuthError) setError(err.message);
}
```
===
# Next.js
> Components, server helpers and route protection.
Source: https://docs.zevauth.com/sdk/nextjs
---
import Callout from '../../../components/Callout.astro';
```bash
npm install @zevauth/nextjs
```
The package has two entry points, and the split matters: everything that
renders is a client component, and everything that verifies is server-only.
```ts
import { SignIn, useUser } from '@zevauth/nextjs'; // client
import { getAuth } from '@zevauth/nextjs/server'; // server
```
The client entry is marked `'use client'`, so importing `` into a
server component works instead of failing with a hooks error.
## Provider
```tsx
// app/layout.tsx
import { ZevAuthProvider } from '@zevauth/nextjs';
export default function RootLayout({ children }) {
return (
{children}
);
}
```
## Reading auth on the server
```ts
import { getAuth } from '@zevauth/nextjs/server';
export async function GET(request: Request) {
const auth = await getAuth(request, {
environmentId: process.env.ZEVAUTH_ENVIRONMENT_ID!,
});
if (!auth.isSignedIn) return new Response('Unauthorized', { status: 401 });
return Response.json({ userId: auth.userId });
}
```
This verifies **offline**, against your environment's published keys. It does
not call ZevAuth on every request, so our availability is not in the path of
every page you serve.
An expired, tampered or missing token all return `isSignedIn: false` rather
than throwing. To refuse instead, use `requireAuth`, which throws a `Response`:
```ts
const auth = await requireAuth(request, { environmentId });
```
### Checking permissions
```ts
if (!auth.has({ permission: 'org:members:manage' })) {
return new Response('Forbidden', { status: 403 });
}
```
Offline verification cannot know a session was revoked. That is the trade a
stateless token makes. Access tokens are short-lived, so the window is small.
If you need to react to a sign-out immediately, call `/v1/me`, which checks the
session.
## Middleware
```ts
// middleware.ts
import { createZevAuthMiddleware } from '@zevauth/nextjs/server';
export default createZevAuthMiddleware({
environmentId: process.env.ZEVAUTH_ENVIRONMENT_ID!,
protected: ['/dashboard', '/settings'],
});
export const config = { matcher: ['/((?!_next|.*\\..*).*)'] };
```
Protection is **opt-in**, path by path. A signed-out visitor to a protected
path is redirected to `/sign-in` with `redirect_url` set to where they were
going.
Protect-by-default sounds safer and is not: it protects the sign-in page too,
and the callback it redirects to, so the app redirect-loops. Listing what to
protect is explicit, and the mistake it allows, forgetting a route, is caught
by the server check you need anyway.
A string rule covers everything beneath it, so `/dashboard` also protects
`/dashboard/billing`. Regular expressions work too, and `public` wins over
`protected`:
```ts
createZevAuthMiddleware({
environmentId,
protected: [/^\/app/],
public: ['/app/health'],
});
```
===
# JavaScript
> The framework-agnostic client.
Source: https://docs.zevauth.com/sdk/javascript
---
import Callout from '../../../components/Callout.astro';
```bash
npm install @zevauth/js
```
`@zevauth/react` is built on this. Use it directly for Vue, Svelte, vanilla
JavaScript, or anywhere you want the flows without the components.
```ts
import { createZevAuth } from '@zevauth/js';
const zevauth = createZevAuth({ publishableKey: 'pk_test_...' });
await zevauth.load();
```
`load()` reads your environment, restores any stored session, and completes a
[hosted-page handoff](/concepts/hosted-pages) if the URL carries one. Call it
once at start-up; calling a flow before it resolves is fine.
## Signing in
```ts
await zevauth.signIn.withPassword({ identifier, password });
await zevauth.signIn.startMagicLink({ email });
await zevauth.signIn.startEmailCode({ email });
await zevauth.signIn.verifyEmailCode({ email, code });
zevauth.signIn.withZevId(); // full-page redirect
```
`identifier` takes an email **or** a username. The server tells them apart, so
your form needs one field.
`startMagicLink` and `startEmailCode` answer identically whether or not the
address belongs to anybody. That is deliberate: a different response would turn
either into a way to test whether somebody has an account with you.
## Signing up
```ts
const { requiresEmailVerification } = await zevauth.signUp.create({
email,
password,
firstName,
});
```
Whether a new account is signed in immediately or must confirm its address
first is your setting, so read the result rather than assuming.
## The session
```ts
zevauth.user; // the signed-in user, or null
zevauth.organization; // the active organization, or null
await zevauth.getToken(); // a valid access token, refreshed if needed
await zevauth.signOut();
```
Subscribe to changes:
```ts
const unsubscribe = zevauth.addListener((state) => {
render(state.user);
});
```
The listener is called immediately with the current state, so you never render
a frame against something you have not been told about.
## Storage, and what it costs
The access token is held **in memory only**. The refresh token is persisted, so
a session survives a reload.
That means the refresh token is readable by any script on your page. It is
worth being plain about: an XSS on your site can take it. What makes that
survivable rather than fatal is rotation with reuse detection. A stolen token
buys at most one refresh before the real client's next refresh trips the alarm
and the whole session is burned.
It is not a substitute for not having XSS.
You can supply your own storage:
```ts
createZevAuth({
publishableKey: 'pk_test_...',
storage: { get, set, remove },
});
```
Never refresh a session yourself, in parallel with the SDK. Refresh tokens
rotate and a token presented twice is treated as theft, which ends the session
on every device. The SDK serialises refreshes across tabs with a lock for
exactly this reason.
## The signed-in person
`client.me` reads and writes the current user's own profile. It is deliberately
narrow: `publicMetadata` is not here because it travels inside the access token
and your backend trusts it for authorization, and `email` is not here because
changing an address is a flow that needs proof rather than a field edit.
```ts
const { user, organizations, organization } = await client.me.fetch();
await client.me.update({ firstName: 'Ada', lastName: 'Lovelace' });
```
### Profile pictures
Uploading is two calls, not one. The bytes go to storage first and the URL is
saved second, which is what lets you show the picture the moment it is chosen.
It also keeps the two failures apart: an upload that fails is a network problem
worth retrying, and a save that fails is a permission problem worth reading.
```ts
const url = await client.me.uploadAvatar({
file,
onProgress: (fraction) => setProgress(fraction),
});
await client.me.update({ imageUrl: url });
```
The file never touches the ZevAuth API. It goes straight to object storage with
a short-lived signed URL, and the object is keyed by the hash of its contents,
so uploading the same picture twice costs one upload.
Pass `imageUrl: null` to remove a picture. Leaving the field out leaves it
alone, which is a different thing: most profile patches touch a name and should
not clear an avatar on the way past.
`imageUrl` still accepts any https URL. If you host your users' pictures
yourself, or their picture came from a social login, nothing changes. Uploading
is an addition, not a replacement.
Uploads need a secure context, because the hash is computed with
`crypto.subtle`. That means https in production and `localhost` in development.
===
# Backend
> Manage users and organizations from your server, with a secret key.
Source: https://docs.zevauth.com/sdk/backend
---
import Callout from '../../../components/Callout.astro';
```bash
npm install @zevauth/backend
```
```ts
import { createZevAuthBackend } from '@zevauth/backend';
const zevauth = createZevAuthBackend({
secretKey: process.env.ZEVAUTH_SECRET_KEY!,
});
const user = await zevauth.users.create({
email: 'ada@example.com',
publicMetadata: { plan: 'pro' },
});
```
This is not `@zevauth/js`. That package carries a **publishable** key and is
meant to ship inside your bundle. This one carries a **secret** key, which can
read every user's address, set anybody's password, and delete an entire
organization.
Never import it into client code, and never hardcode the key. It refuses a
`pk_…` key at construction, because the reverse mistake — reaching for this in
a browser and pasting a secret key to make it work — is the one that cannot be
undone.
## Users
```ts
// One page. `next` is opaque: pass it back as `after` to continue.
const page = await zevauth.users.list({ limit: 50, query: 'ada@' });
const user = await zevauth.users.get('usr_...');
```
### Creating
Creates the account directly. No sign-up flow runs and no email is sent.
```ts
await zevauth.users.create({
email: 'ada@example.com',
password: 'optional',
emailVerified: true,
publicMetadata: { plan: 'pro' },
privateMetadata: { stripeCustomerId: 'cus_...' },
});
```
Two fields here are only available to a backend, and that is the reason this
client needs a secret key:
- **`emailVerified: true`** trusts the address without proving it. That is the
migration case: the system you are replacing already confirmed it. Reachable
from a browser it would make verification decorative.
- **`password` is optional.** An account without one still signs in by magic
link or a social provider, which is usually what an import wants — create the
accounts, let people set a password when they first arrive.
### Updating
```ts
await zevauth.users.update('usr_...', {
firstName: 'Augusta',
publicMetadata: { plan: 'enterprise' },
});
```
Omitting a field leaves it alone. Sending `null` clears it, which is a
different thing: most patches touch a name and should not clear an avatar on
the way past.
Setting `password` here does **not** revoke sessions. A person changing their
own password is usually reacting to a suspicion and wants every other device
gone; a backend setting one is usually provisioning, and signing everybody out
mid-migration would be its own outage. Use `revokeSessions` when that is the
intent.
`publicMetadata` travels inside the access token, so your own backend can read
it without a round trip — and your users cannot write it. `privateMetadata`
never leaves the server. Put anything an authorization decision depends on in
one of these two, never in `unsafeMetadata`, which the user's own client can
set.
### Blocking, revoking and deleting
```ts
const { sessionsRevoked } = await zevauth.users.setBlocked('usr_...', true);
await zevauth.users.revokeSessions('usr_...');
await zevauth.users.delete('usr_...');
```
Blocking revokes every session the person holds, in the same transaction as the
status change. That is the point of it: an access token lives for minutes and a
refresh token for weeks, so an account "blocked" without revocation stays signed
in on every device it was already signed in on — which is every device that
matters, if the reason for blocking is that somebody else has one.
`delete` is a real delete, not a flag. Somebody exercising a right to erasure is
not served by a row that still holds their address. Sessions, identities and
organization memberships go with it.
## Organizations
```ts
const { organization } = await zevauth.organizations.create({
name: 'Acme Corp',
createdBy: 'usr_...', // becomes the owner
});
const detail = await zevauth.organizations.get(organization.id);
await zevauth.organizations.update(organization.id, { name: 'Acme' });
await zevauth.organizations.addMember(organization.id, { userId: 'usr_...' });
await zevauth.organizations.removeMember(organization.id, 'usr_...');
await zevauth.organizations.delete(organization.id);
```
There is no permission check on these, and that is deliberate. The equivalent
end-user calls re-read the caller's live `org:manage` membership, because the
caller is a person whose standing can change. This caller is your backend
holding a secret key: it owns the environment, so there is no membership to
consult.
Deleting an organization removes it and its memberships. The people survive —
deleting users because the company they belonged to was deleted would be a
data-loss bug wearing a cascade.
## Retries
Requests that could not be answered are retried automatically: a dropped
connection, a timeout, a 429, or a 502/503/504.
A bare **500 is not retried**. Our code ran and may have written something
before it failed, so repeating it is how one `create` becomes two records — in a
nightly job, silently, with nobody watching. Nothing 4xx is retried either; it
would fail identically forever.
```ts
createZevAuthBackend({
secretKey: process.env.ZEVAUTH_SECRET_KEY!,
timeoutMs: 15_000, // one attempt gives up after this
maxRetries: 2,
});
```
## Errors
Everything throws `ZevAuthError`, carrying the message the API returned, a
`code`, and the HTTP `status`.
```ts
import { ZevAuthError } from '@zevauth/backend';
try {
await zevauth.users.get('usr_nope');
} catch (err) {
if (err instanceof ZevAuthError && err.status === 404) {
// ...
}
}
```
A user belonging to another environment is a **404**, not a 403. Telling the two
apart would confirm that a guessed id belongs to somebody, which is a membership
oracle across tenants.
===
# Components
> The drop-in UI, and how it picks up your brand.
Source: https://docs.zevauth.com/sdk/components
---
import Callout from '../../../components/Callout.astro';
import { ComponentPreview } from '../../../previews/ComponentPreview';
Every component renders what your environment has **enabled**. Turn magic
links on in the console and they appear; turn passkeys off and they stop being
offered. You do not redeploy.
Every preview on this page is the real component, running in your browser
against fixture data. Nothing has a key and no request leaves the page, so the
buttons do nothing. Use the light and dark switch to see what your users get.
## SignIn
```tsx
```
Renders whichever of password, magic link, email code and ZevID your
environment accepts. Enable more in the console and they appear here, with no
change to your code:
With several enabled that gets long. `secondaryMethods="collapsed"` folds the
alternatives behind a toggle:
```tsx
```
Which is right depends on the method you want people to reach for, so it is
yours to choose rather than ours to decide.
If none are enabled it says so, rather than rendering an empty box.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `afterSignInUrl` | `string` | | Where to go once signed in. Omit it and the component simply stops rendering the form, leaving your router in charge. |
| `secondaryMethods` | `'inline' \| 'collapsed'` | `'inline'` | Whether the passwordless alternatives are laid out or folded away. |
| `children` | `ReactNode` | | Rendered instead of the form once somebody is signed in. |
| `className` | `string` | | Applied to the outer wrapper. |
## SignInWithZevId
Just the SSO button, for a sign-in page you built yourself.
```tsx
```
No card, no identity block, no attribution: it is a control inside your layout
rather than a screen of ours. You do not have to adopt `` to offer
ZevID.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `redirectUrl` | `string` | current page | Where to return after signing in. |
| `label` | `string` | `'Continue with ZevID'` | |
| `variant` | `'primary' \| 'secondary'` | `'secondary'` | `secondary` suits a row of provider buttons. |
| `className` | `string` | | |
Leave `afterSignInUrl` off and the component simply stops rendering the form
once somebody signs in, so your own router decides what happens next.
## SignUp
```tsx
```
If your environment requires email confirmation, this shows the "check your
email" state instead of signing the person in.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `afterSignUpUrl` | `string` | | Where to go once the account exists and a session started. |
| `children` | `ReactNode` | | Rendered instead of the form once signed in. |
| `className` | `string` | | |
## UserButton
```tsx
```
An avatar and a menu. Renders nothing when nobody is signed in, so it can sit
beside a sign-in button in your header without the two fighting.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `afterSignOutUrl` | `string` | | Where to go after signing out. |
| `className` | `string` | | |
## UserProfile
```tsx
```
Picture, name and password, for a settings page. The email is shown but not
editable: changing an address has to be proved before it takes effect, so it is
a flow of its own rather than a text input.
The picture saves on its own rather than waiting for the form's button. By the
time it appears on screen it has already been uploaded, and a Save that seemed
to be needed afterwards would ask for a second commitment to something already
done.
The password fields start closed. Most visits to a profile page are to change a
name, and two empty password boxes on arrival invite a password manager to fill
them and a person to wonder what is wrong.
Render one half at a time if your settings area has its own navigation:
```tsx
```
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `section` | `'all' \| 'profile' \| 'security'` | `'all'` | Render both, or one, so they can live on separate pages. |
| `defaultPasswordOpen` | `boolean` | `false` | Whether the password fields start open. |
| `className` | `string` | | |
## OrganizationSwitcher
```tsx
```
Hides itself for somebody who belongs to no organizations, because a switcher
with one option cannot do anything.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `allowPersonal` | `boolean` | `true` | Offer a way back to personal scope. |
| `className` | `string` | | |
## OrganizationProfile
```tsx
```
Lists members and, for anybody with `org:members:manage`, offers the controls
to change them. The organization's picture appears above them for anybody with
`org:manage`, which is a separate permission on purpose: inviting a colleague
and renaming the company are not the same authority.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `className` | `string` | | |
## ImageField
The picture control from `` and ``, exported
on its own so a custom settings screen gets the same behaviour rather than
reimplementing the upload-then-save sequence around `uploadAvatar`.
```tsx
client.me.uploadAvatar({ file, onProgress })}
onSave={(imageUrl) => client.me.update({ imageUrl })}
/>
```
`upload` and `onSave` are separate so you decide what the URL is saved to. That
is what lets the same control serve a user's avatar and an organization's
image without knowing which it is looking at.
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `label` | `string` | | |
| `value` | `string \| null` | | The picture now, or null. |
| `upload` | `(file, onProgress) => Promise` | | Resolves to the URL to save. |
| `onSave` | `(url: string \| null) => Promise` | | Called with `null` on removal. |
| `shape` | `'circle' \| 'square'` | `'circle'` | Round for a person, square for an organization. |
| `isDisabled` | `boolean` | `false` | |
## Styling
The components bring their own CSS, injected once on first render. There is no
stylesheet to import. That is one less line to remember, and one less thing to
break in a bundler that does not handle CSS imports.
Everything is prefixed `zv-` and nothing styles bare elements, so the styles
cannot reach your markup and yours cannot reach ours.
Your brand arrives from the API: the accent, logo, radius and font you set in
the console are applied as CSS variables. Here is the same component with a
developer's own colour:
The accent is **contrast-corrected
server-side** for light and dark surfaces, so a colour that is beautiful on
white does not become invisible on near-black. The console preview, the hosted
pages and these components all paint the same value.
`colorScheme: system`, the default, follows the viewer's own light and dark
preference. A sign-in box blazing white inside somebody's dark application
looks like it came from a different site, which is an instinct we would rather
not dull.
## The ZevAuth badge
"Secured by ZevAuth" appears under the card. Whether it can be removed is
decided by your plan, on the server. A client cannot turn it off. Development
always shows it, so you never design against a screen production will not give
you.
===
# Organizations
> Teams, roles, and the session acting as one.
Source: https://docs.zevauth.com/sdk/organizations
---
import Callout from '../../../components/Callout.astro';
An organization is a group of your users. A session can act **as** an
organization, and while it does, the tokens it holds say so.
## The active organization
```tsx
const { organizations, setActive } = useOrganizationList();
const { organization } = useOrganization();
await setActive('org_...'); // null returns to personal scope
```
Switching **rotates the session's tokens**, because the organization is a claim
inside them. An app holding the old access token would keep acting as the old
organization until it expired, which is why the rotation is not optional.
## Roles and permissions
A member holds a role, and a role grants permissions. Your backend receives
both in the token:
```json
{
"sub": "user_...",
"org_id": "org_...",
"org_role": "admin",
"org_permissions": ["org:members:read", "org:members:manage"]
}
```
Permissions are sent alongside the role rather than left for you to derive from
it. The point of a permission model is that the mapping can change without
every consumer reimplementing it.
Check them in the UI:
```tsx
```
And on your server, which is where it counts:
```ts
const auth = await getAuth(request, { environmentId });
if (!auth.has({ permission: 'org:members:manage' })) {
return new Response('Forbidden', { status: 403 });
}
```
## Managing members
```ts
await zevauth.organizations.addMember({ email: 'colleague@example.com' });
await zevauth.organizations.setMemberRole({ userId, role: 'admin' });
await zevauth.organizations.removeMember({ userId });
```
Adding somebody requires them to have an account with you already.
Authorisation for these is checked against the **live membership**, not the
token's claims. Claims are a snapshot: an admin demoted a minute ago still
carries the old permissions until their next refresh. That is fine for deciding
what to draw and not fine for removing somebody from a company.
Two rules apply underneath:
**Anybody may remove themselves.** An organization you cannot leave is a trap,
so leaving needs no permission.
**The last owner cannot be removed** by anyone, including themselves.
Otherwise the organization would be left with nobody able to administer it.
## In the UI
```tsx
```
`` decides what to show from the token's claims, so a
control can briefly be visible to somebody who was just demoted. Pressing it
produces a refusal, not a change. Hiding the button is a courtesy; refusing the
request is the security.
## Changing the organization
Renaming an organization or changing its picture needs the `org:manage`
permission — a different one from `org:members:manage`, because plenty of teams
want people who can invite colleagues without being able to rename the company
on every screen in the product.
```ts
await client.organizations.update({ name: 'Acme Corp' });
```
The picture works the same way as a user's avatar: upload, then save.
```ts
const url = await client.organizations.uploadImage({ file });
await client.organizations.update({ imageUrl: url });
```
`imageUrl: null` removes it. Omitting the field leaves it alone.
The permission is checked against the caller's **live** membership, not against
the `org_permissions` claim in their token. Claims are a snapshot from when the
token was minted, so somebody demoted a minute ago still carries the old ones
until their next refresh. That is fine for deciding what to draw and not enough
to decide what to allow.
===
# Sessions and tokens
> What a session is made of, and how it stays alive.
Source: https://docs.zevauth.com/concepts/sessions
---
import Callout from '../../../components/Callout.astro';
Signing in produces two tokens.
**An access token.** A short-lived signed JWT. This is what you send to your
own API, and what your API verifies. It carries who the person is, which
session it belongs to, and which organization the session is acting as.
**A refresh token.** An opaque string, longer-lived, used only to get a new
access token. It never goes to your API.
## Rotation, and why it is strict
Every refresh **rotates** the refresh token: the old one is spent and a new one
issued. If a spent token is ever presented again, the entire session is
revoked on every device, immediately.
That is deliberate. A replayed token means either a stolen copy is being used
or the real client raced itself, and the server cannot tell those apart. The
safe answer is the same in both cases: end the session. An attacker gets at
most one refresh before the real user's next refresh makes the theft visible.
There is no grace window. A client that refreshes twice at the same moment ends
its own session, on every device. `@zevauth/js` serialises refreshes across
tabs with a browser lock. If you write your own client, you have to do the
same.
## Where tokens live
The SDK keeps the access token **in memory** and persists only the refresh
token. On a fresh page load it exchanges the refresh token for a new access
token, which takes one request and starts the session clean.
## Revocation
Signing out revokes the session. So does changing a password, which ends every
**other** session and keeps the one making the change. Signing somebody out of
the browser they just used to secure their account punishes exactly the
behaviour you want.
A revoked session cannot be detected by verifying a token offline; that is what
stateless tokens trade away. Access tokens are short-lived so the window is
small, and `/v1/me` checks the session if you need certainty.
## Claims
```json
{
"sub": "user_2xK…",
"sid": "sess_9fQ…",
"iss": "https://api.zevauth.net/v1/env_live_…",
"aud": "env_live_…",
"exp": 1767225600,
"email": "ada@example.com",
"email_verified": true,
"public_metadata": {},
"org_id": "org_7bC…",
"org_role": "admin",
"org_permissions": ["org:members:read"]
}
```
`public_metadata` is yours to set from your backend and is readable by anyone
holding the token, including the user. Private metadata never leaves your
server, and a user cannot write either.
===
# Verifying tokens
> Checking an access token on your backend, in any language.
Source: https://docs.zevauth.com/concepts/verifying-tokens
---
import Callout from '../../../components/Callout.astro';
A ZevAuth access token is an ordinary JWT. You verify it with your
environment's public key, which we publish. **Your backend never calls us to
check a token**, so an outage of ours is not an outage of yours.
## Discovery
Every environment publishes an OIDC discovery document:
```
https://api.zevauth.net/v1/{environmentId}/.well-known/openid-configuration
```
It gives you the `issuer` and the `jwks_uri`. Read both from there rather than
building them yourself. The issuer is an identity, not a URL you can assume,
and a self-hosted deployment will not match a guess.
## Node
```ts
import { verifyZevAuthToken } from '@zevauth/nextjs/server';
const claims = await verifyZevAuthToken(token, {
environmentId: process.env.ZEVAUTH_ENVIRONMENT_ID!,
});
```
Or with `jose` directly, in any framework:
```ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
const issuer = `https://api.zevauth.net/v1/${environmentId}`;
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience: environmentId,
});
```
## Python
```python
from jwt import PyJWKClient
import jwt
issuer = f"https://api.zevauth.net/v1/{environment_id}"
jwks = PyJWKClient(f"{issuer}/.well-known/jwks.json")
claims = jwt.decode(
token,
jwks.get_signing_key_from_jwt(token).key,
algorithms=["RS256"],
issuer=issuer,
audience=environment_id,
)
```
## Always check the audience
Verify `aud` against your environment id. Each environment signs with its own
key, so a token cannot verify against the wrong one. But a project with
development and production both live has two valid tokens, and without an
audience check a test-mode token would be accepted by a production endpoint.
## Cache the key set
Fetch the JWKS once and keep it. Libraries like `jose` and `PyJWKClient` do
this for you, and refetch when they meet a key id they do not know, which is
what a key rotation looks like. Building a new client per request defeats the
cache and fetches the document on every call.
Retiring keys stay published for a while after a rotation, so tokens signed a
minute before it keep verifying for the rest of their life.
===
# Hosted pages
> The screens your users reach from an email.
Source: https://docs.zevauth.com/concepts/hosted-pages
---
import Callout from '../../../components/Callout.astro';
Some flows begin in an inbox. A confirmation link, a magic link, a password
reset. The person clicks it hours later, in a different browser, with no
session.
Those links land on **accounts.zevauth.com**, wearing your brand.
```
https://accounts.zevauth.com/verify?token=…&t=env_live_…
```
- `/verify` confirms an email address
- `/sign-in` completes a magic-link sign-in
- `/reset-password` sets a new password
The `t` parameter names your environment so the page knows whose brand to
wear. It is not a secret. It is the same id inside every publishable key, and
the token is the only thing that grants anything.
## Where the session actually starts
**Not on our page.** A hosted page is on our domain; your users' sessions
belong on yours, and we cannot set a cookie for your domain.
So the magic-link page never holds a session. It redeems the link, mints a
one-time code, and redirects to a callback **you registered**:
```
https://your-app.example.com/callback?code=hoc_…
```
Your app exchanges that code for a session on its own origin, with its own key.
The SDK does this automatically inside `load()`, and strips the spent code from
the URL afterwards.
```ts
await zevauth.load(); // exchanges ?code= if present
```
The same shape OAuth uses, and for the same reason: a credential should be
minted where it is going to live.
The code is single-use and expires in sixty seconds. That is a redirect hop,
not a session. A longer window buys the user nothing and buys anyone who
captures the URL an opportunity.
## Registering a callback
Add your callback under **API keys** in the console. An environment with none
cannot complete a hosted sign-in, and says so rather than failing quietly.
A requested redirect must match a registered one **exactly**. Prefix matching
is how `https://app.example.com` ends up satisfied by
`https://app.example.com.attacker.test`.
## Using your own pages instead
You can handle these flows yourself. Take the token from the link and call the
API directly. See [Authentication](/api/authentication). The hosted pages are
the fallback, not the destination we insist on.
===
# Branding
> Making every screen look like your product.
Source: https://docs.zevauth.com/concepts/branding
---
import Callout from '../../../components/Callout.astro';
Set your brand once in the console, under **Customisation**. It applies
everywhere your users see us: the SDK components, the hosted pages, and every
authentication email.
## What you set
**A logo.** An https URL you host, so you can change it without us. A wide mark
works better than a square icon: it sits above the form at a fixed height, so a
tall logo shrinks to nothing.
**One accent colour.** Not a palette. Hover, active and disabled states are
derived from it, and contrast is our problem to solve rather than yours to
guess at.
**A radius, a font and a colour scheme.**
## Contrast is handled for you
Your accent is adjusted server-side so it stays legible on both light and dark
surfaces, and the corrected values are what every surface paints. A colour that
is beautiful on white can be invisible on near-black; a single stored hex would
make one of the two unreadable.
The text **on** your accent is chosen the same way, so a pale yellow brand gets
dark text on its buttons rather than white.
This is computed once, on the server. The console preview, the hosted pages and
the SDK components all read the same resolved values. Three consumers running
their own contrast rule is three chances to disagree, and the disagreement is
invisible until somebody screenshots a button nobody can see.
## Your name under the logo
By default your app's name prints under your logo. If your logo is already a
wordmark, turn that off, or your name appears twice.
With no logo the name always shows: it is then the whole identity, and a card
with no identity at all is worse than either.
## The ZevAuth badge
"Secured by ZevAuth" appears under every card. Removing it is a paid feature,
and the decision is made on the server. A client cannot turn it off.
Development always shows it, on every plan, so you never design against a
screen production will not give you.
===
# Authentication
> Signing people in and keeping them signed in.
Source: https://docs.zevauth.com/api/authentication
---
import Endpoint from '../../../components/Endpoint.astro';
import Callout from '../../../components/Callout.astro';
These take a **publishable key**. They are designed to be called from a
browser.
## Sign up
```json
{ "email": "ada@example.com", "password": "correct horse battery staple" }
```
Returns the user and, unless your environment requires email confirmation
first, a session.
## Sign in
```json
{ "email": "ada@example.com", "password": "…" }
```
`email` accepts an address **or** a username. Failures are uniform: a wrong
password and an unknown account return the same error, so the endpoint cannot
be used to enumerate your users.
```json
{
"user": { "id": "user_…", "email": "ada@example.com", "…": "…" },
"session": {
"accessToken": "eyJ…",
"refreshToken": "rt_…",
"expiresIn": 900,
"tokenType": "Bearer",
"organization": null
}
}
```
## Passwordless
```json
{ "email": "ada@example.com" }
```
Both answer the same way for any address:
```json
{ "sent": true, "message": "If that address has an account, we have sent it a message." }
```
Codes are redeemed with:
```json
{ "email": "ada@example.com", "code": "418293" }
```
Magic links are redeemed by the [hosted page](/concepts/hosted-pages), which
returns a one-time code your app exchanges.
## Refresh
```json
{ "refreshToken": "rt_…" }
```
Refresh tokens rotate. Presenting a spent one revokes the whole session on
every device. The server cannot tell a stolen replay from a client racing
itself, so it assumes the worse. Never refresh in parallel.
## Switch organization
```json
{ "refreshToken": "rt_…", "organizationId": "org_…" }
```
Pass `null` to return to personal scope. Returns a fresh user and session,
because the organization is a claim inside the tokens.
## Sign out
```json
{ "refreshToken": "rt_…" }
```
## Environment
Which environment your key resolves to, which sign-in methods are enabled, your
limits, and your branding. The SDK calls this once at start-up.
===
# Users
> Reading your userbase, and what a person may change themselves.
Source: https://docs.zevauth.com/api/users
---
import Endpoint from '../../../components/Endpoint.astro';
import Callout from '../../../components/Callout.astro';
Two different things live here, and the difference is who is asking.
## Your userbase: secret key
Your administrative view. These read **anybody**, so they take a secret key and
must be called from your server.
## The signed-in person: session token
Returns the caller, the organizations they belong to, and which one the session
is currently acting as.
```json
{
"user": { "id": "user_…", "email": "ada@example.com", "firstName": "Ada", "…": "…" },
"organizations": [
{ "id": "org_…", "name": "Acme", "slug": "acme", "role": "admin", "imageUrl": null }
],
"organization": null
}
```
There is no id in the path, and that is deliberate: a path parameter invites an
authorisation check that somebody eventually forgets. With no parameter there
is nothing to forget.
The `organizations` list is what a switcher renders. `switch-organization` can
change the active one, but nothing else can tell you what the choices are.
### Update your own profile
```json
{ "firstName": "Ada", "lastName": "Lovelace", "username": "ada" }
```
`imageUrl` must be an https URL. Send `null` to remove the picture; leaving the
field out leaves it alone.
`publicMetadata` is **not** editable here. It travels inside the access token
and your backend trusts it for authorisation. A user who could write it could
set `{"role":"admin"}` and hand it to a server that believes tokens. It stays
writable only through the secret-key API, where you decide.
`email` is not editable either. Changing an address has to be proved before it
takes effect, or anybody who finds an unlocked laptop moves the account
somewhere they control.
### Ask for an upload URL
```json
{
"purpose": "user_avatar",
"contentType": "image/png",
"contentLength": 84213,
"sha256": "e3b0c44298fc1c14…",
"width": 512,
"height": 512
}
```
Returns a short-lived signed URL to `PUT` the bytes to, plus the `publicUrl` to
save afterwards with `PATCH /v1/me`. The file never passes through this API.
`sha256` is the hex digest of the file and becomes the object key, so uploading
the same picture twice costs one upload: the second response comes back with
`alreadyExists: true` and no `uploadUrl`, and you save the `publicUrl` directly.
PNG, JPEG and WebP, up to 1 MB. SVG is refused: an SVG is a document that can
carry a script, and these images render on sign-in screens.
`purpose` may be `user_avatar` or `organization_image`. The second needs the
`org:manage` permission on the organization, checked against the live
membership. Branding assets belong to you rather than to your users and are
uploaded from the console, not from here.
When object storage is not configured on the deployment, this returns a 400
saying so. Every image field still accepts an https URL you host yourself,
which is what they did before uploads existed.
### Change your password
```json
{ "currentPassword": "…", "newPassword": "…" }
```
The current password is required even though the session already proves who
signed in. A session is proof that somebody signed in, not that the person at
the keyboard right now is the same one.
Every **other** session is revoked. The one making the change survives.
```json
{ "changed": true, "otherSessionsRevoked": 3 }
```
===
# Organizations
> Creating teams from your backend, and managing them from your app.
Source: https://docs.zevauth.com/api/organizations
---
import Endpoint from '../../../components/Endpoint.astro';
import Callout from '../../../components/Callout.astro';
## From your backend: secret key
Full administrative control over any organization in the environment.
## From your app: session token
These act on the organization the **session is currently acting as**, so there
is no id in the path.
Adding somebody:
```json
{ "email": "colleague@example.com", "role": "member" }
```
They must already have an account in your environment. If they do not, the
error says so. The caller is an authenticated admin adding a colleague, and
being vague there would just leave them retyping an address that was never
going to work.
Renaming it, or changing its picture:
```json
{ "name": "Acme Corp", "imageUrl": "https://assets.example.com/acme.png" }
```
This needs `org:manage`, which is a different permission from
`org:members:manage`. Plenty of teams want people who can invite colleagues
without being able to rename the company on every screen in the product.
`imageUrl` must be https. Send `null` to remove it; omitting the field leaves it
alone. To upload rather than link, ask for a ticket at
[`POST /v1/me/assets/upload-url`](/api/users/#ask-for-an-upload-url) with
`purpose: "organization_image"`, then save the URL it returns here.
Permission is checked against the **live membership**, not the token's claims.
Claims are a snapshot: an admin demoted a minute ago still presents a token
saying they can manage members. That is tolerable for deciding what to draw and
not tolerable for removing somebody from a company.
Two rules apply to removal:
**Anybody may remove themselves.** Leaving needs no permission, because an
organization you cannot leave is a trap.
**The last owner cannot be removed**, by anyone including themselves, or the
organization would be left with nobody able to administer it.
```json
{ "error": { "code": "forbidden", "message": "Cannot remove the last owner. Transfer ownership first." } }
```
===
# Errors
> The error envelope, and the codes worth branching on.
Source: https://docs.zevauth.com/api/errors
---
import Callout from '../../../components/Callout.astro';
Every error has the same shape, including unexpected ones:
```json
{ "error": { "code": "invalid_request", "message": "Country must be a two-letter ISO code." } }
```
Branch on `code`. `message` is written for a person to read and may be
reworded; `code` is part of the contract.
## Codes
| Status | Code | Means |
| --- | --- | --- |
| 400 | `invalid_request` | The request was malformed or a value was rejected. |
| 401 | `unauthorized` | No credential, or one that does not verify. |
| 403 | `forbidden` | Authenticated, but not allowed to do this. |
| 404 | `not_found` | No such thing, or nothing you may see. |
| 409 | `conflict` | Something already exists: a taken username, a duplicate. |
| 429 | `rate_limited` | Too many requests. Back off and retry. |
| 500 | `internal_error` | Ours. Quote the reference if you contact support. |
## What errors deliberately do not tell you
Sign-in and passwordless endpoints answer **identically** whether or not an
address belongs to anybody. A wrong password and an unknown account produce the
same error, and requesting a magic link for an unknown address succeeds.
That is not vagueness for its own sake: a distinguishable response turns any of
those endpoints into a way to test whether a given person has an account with
you.
## Internal errors say nothing about the cause
A `500` carries a generic message and a reference:
```json
{
"error": {
"code": "internal_error",
"message": "Something went wrong on our side. Quote reference 4f2a9c11 if you get in touch."
}
}
```
The real error is in our logs against that reference. Database errors carry the
SQL statement and its parameters, so forwarding them would hand out a map of
the schema. The reference gets support to the exact failure in seconds without
it.