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