ZevAuth
Sign up

Getting started

Use cases

Complete, working setups for the shapes most products actually need.

Reference pages answer “what does this field do”. This page answers “how do I build the thing I came here to build”. Each one is a whole setup: what to switch on in the console, what to write in code, and what it gets you.

A SaaS that confirms every address before letting anyone in

Financial tools, anything with payouts, anything where an unconfirmed address is a liability.

In the console

  1. Authentication → Identifiers → Email: Verify the address At sign-up.
  2. Turn Let them in before it is confirmed off.

In code

import { ZevAuthProvider, SignIn, SignUp } from '@zevauth/react';

<ZevAuthProvider publishableKey={import.meta.env.VITE_ZEVAUTH_PUBLISHABLE_KEY}>
  <SignUp afterSignUpUrl="/welcome" />
</ZevAuthProvider>

What happens. Sign-up creates the account and issues no session. <SignUp /> shows the confirmation screen, with a code field when your environment sends codes and link instructions when it sends links.

If they close the tab and come back a week later, signing in is refused with email_not_verified and we send a fresh message. They are returned to the confirmation screen rather than stranded with an expired link.

A product that lets people in, then nags them

Marketplaces, social products, anything where friction at sign-up costs you more than an unconfirmed address does.

In the console

  1. Verify the address At sign-up.
  2. Leave Let them in before it is confirmed on.

In code

import { SignedIn, EmailVerified, EmailUnverified } from '@zevauth/react';

<SignedIn>
  <EmailUnverified>
    <Banner>
      Confirm your email to enable payouts. <ResendButton />
    </Banner>
  </EmailUnverified>

  <EmailVerified>
    <PayoutSettings />
  </EmailVerified>
</SignedIn>

Resending is one call:

await zevauth.signUp.resendVerification({ email: user.email });

On your own API, read the claim from the token you already verify:

const { payload } = await jwtVerify(token, jwks, { issuer, audience });
if (!payload.email_verified) {
  return Response.json({ error: 'Confirm your email first.' }, { status: 403 });
}

Sign in with ZevID, tested on localhost

The ZevID button needs one thing beyond being switched on, and it is the thing almost everyone misses on the first try.

In the console

  1. Authentication → Social providers: turn ZevID on.
  2. Project settings → Domains & callbacks: add where the person should land afterwards. For local work that is your dev server, for example http://localhost:5173/. Development environments accept http on loopback; production must be https.

In code

<SignIn afterSignInUrl="/dashboard" />

That is all. The button redirects to the consent screen and back to the registered callback, where the SDK exchanges the returned code for a session on your own origin.

If the person comes straight back without a session, they arrived with ?error= on your callback. <SignIn /> already shows why; the codes are in the API reference. The common one is zevid_expired: they took more than 15 minutes across OTP, MFA and consent. Offer the button again.

If you see No callback is registered for this environment, step 2 is missing. If you see a URL named as not a registered callback, it does not match an entry exactly: check the scheme, the port and the trailing slash.

A marketplace with buyers, sellers and staff

Staff go in a second project. An environment is a hard tenant boundary: separate user pool, separate keys, separate issuer. A customer token presented to your admin API fails on the signature, not on a check somebody has to remember to write.

Buyers and sellers share one project, typed with metadata:

await zevauth.users.update(userId, { publicMetadata: { type: 'seller' } });

The type travels in the access token, so your API reads it with no extra call, and lists come back filtered:

const { data } = await zevauth.users.list({ metadata: { type: 'seller' } });

publicMetadata is writable only with a secret key, so a value you set is a value only your server set. Full reasoning in Modelling your users.

A sign-in page that looks like the rest of the product

The default is a centered card. This is the split screen most products in this category ship:

In the console: Customisation → Layout → Form right, then pick a Gradient panel and give it a colour and a line of copy.

Or entirely in code:

<SignIn
  layout="split-right"
  aside={
    <div style={{ padding: 48, color: 'white' }}>
      <blockquote>"We moved 40,000 accounts in an afternoon."</blockquote>
      <cite>Ada Lovelace, CTO</cite>
    </div>
  }
/>

The panel hides below 900px and the form takes the whole screen, because a stacked picture pushes the form below the fold on a phone.

A form embedded in a page you have already designed

You have a header, a panel and a font. Our card inside your panel is a box inside a box:

<ZevAuthProvider
  publishableKey="pk_live_..."
  appearance={{
    cardStyle: 'flat',        // no border, background or padding
    showIdentity: false,      // your logo is already above it
    fontFamily: 'inherit',    // your font
    contentAlign: 'top',      // clears your fixed header
    signInTitle: 'Welcome back',
  }}
>
  <MyHeader />
  <main className="my-panel">
    <SignIn secondaryMethods="collapsed" />
  </main>
</ZevAuthProvider>

secondaryMethods="collapsed" puts the email and password form first and folds social sign-in behind More options, which is what you want when most of your users are returning customers with a password.

A B2B app where people belong to companies

const { organization } = await zevauth.organizations.create({
  name: 'Acme Supplies',
  createdBy: userId,
});

The session then carries org_id, org_role and org_permissions. Use the claims to decide what to render, and re-read the membership before anything that changes data: a token minted before somebody was demoted still carries the old permissions until it refreshes.

<OrganizationSwitcher />

See Organizations.

Testing all of it

// A user, and a token, with no password anywhere in your fixtures.
const user = await zevauth.users.create({ email: 'ada@example.com' });
const { accessToken } = await zevauth.sandbox.mintToken(user.id);

// What we would have emailed, including the code.
const { data } = await zevauth.sandbox.emails({ to: 'ada@example.com' });
await zevauth.signUp.verifyCode({ email: 'ada@example.com', code: data[0].code });

// Between CI runs.
await zevauth.sandbox.resetUsers();

Test environments only, and the API refuses every one of these with a live key. See Testing.

Updated at, Wednesday, October 21, 2026