Authentication

app.auth is the entry point for every identity operation. A successful signup or login stores access and refresh tokens on the device and attaches them to future requests.

yourapp.com/login
R

Welcome back

Sign in to continue to Recipes

ada@example.com
••••••••••
Sign in

No account? Create one

The mental model

  • Every signed-in user is a tenant — an isolated row-level namespace. You do not provision tenants; they come into being on first signup.
  • Tokens are stored in localStorage under a key scoped to your appId. The SDK refreshes them transparently when a request returns 401.
  • A BroadcastChannel named pluralize:auth:<appId> keeps open tabs in sync: logging out in one tab logs out in all of them.

Signup

await app.auth.signup('ada@example.com', 'correct horse battery staple', {
  locale: 'en',
});
  • locale is optional — one of 'en' | 'es' | 'nl'. It controls the language of the verification email; unknown values fall back to 'en'.
  • Duplicate emails return a duplicate_email error you can catch via PluralizeError.
  • inviteToken is required when the app's signup mode is invite_only (see below).

Invite-only signup

Apps can gate /api/v1/auth/signup to invited users only. Useful for friends-and-family launches, beta cohorts, or any product that wants a controlled rollout before opening to the public.

Configure from the dashboard

In Dashboard → Signup, pick one of three modes:

  • Open — anyone can sign up. Default for every new app.
  • Invite onlyinviteToken required. Mint tokens from the same page.
  • Closed — every signup returns signup_closed. Use to freeze growth temporarily.

For invite-only, mint tokens with optional maxUses, expiry (in days), and a free-form note (for your own audit trail). The full token string is shown once at mint time; afterwards only the masked form (********…wxyz) is visible. Copy it, share it through your own channel.

Pass the token from the SDK

await app.auth.signup(email, password, {
  inviteToken: new URLSearchParams(window.location.search).get('invite') ?? undefined,
});

Errors

| Status | code | Meaning | | --- | --- | --- | | 400 | invite_required | App is invite_only and the request omitted inviteToken. | | 403 | invite_invalid | Token unknown, revoked, expired, or already used up. | | 403 | signup_closed | App's signup mode is closed — no signups allowed. |

invite_invalid is intentionally vague — it covers all four failure modes so an attacker probing tokens can't distinguish "this token exists but is exhausted" from "this token does not exist".

Race-safety

If the same token still has 1 use left and two browsers click signup at the exact same moment, only one succeeds. The losing request gets invite_invalid. The tenant insert and the invite consumption run in a single database transaction, so a failed signup (e.g. duplicate email) does not burn an invite use.

Tenant viral invites

Tenants of your app can mint their own invite tokens — controlled growth without you manually generating one per friend. Use this when you want a friends-and-family rollout to expand organically without opening signup completely.

The feature is gated by an entitlement on the tenant's plan: set max_tenant_invites to a positive number on the plan(s) where you want to enable it (see Configuring plans). Free plans typically have it at 0; paid plans might give 5-10 invites lifetime.

Mint from the SDK

There are two paths depending on where your access token lives.

Path A — Browser SDK (token in localStorage). If you use the default Pluralize.init({...}) storage, call app.auth.mintInvite directly:

const minted = await app.auth.mintInvite({
  note: 'juan',                  // optional, max 200 chars
  expiresInSeconds: 7 * 86400,   // optional, capped server-side at 30 days, default 7
});
 
const inviteUrl = `https://your-app.example/login?invite=${minted.token}`;
console.log(`${minted.quota.remaining} invites left for this tenant`);

Path B — SSR with httpOnly cookie. If your app stores the access token in an httpOnly cookie (the cookieName pattern from Browser integration), the browser SDK can't read the cookie. Use the server-side helpers from createAuthRouteHandlers and call them through your own proxy route:

// app/api/auth/pluralize/invite-tokens/route.ts
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.mintInvite;
export const GET  = auth.listMyInvites;
// from a "use client" component
const res = await fetch('/api/auth/pluralize/invite-tokens', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ note: 'juan' }),
});
const minted = await res.json();

The handlers read pz_token from the cookie jar and forward to Pluralize with the right Bearer header. Same shape on success and error.

List existing invites

// Browser SDK (Path A)
const { tokens, quota } = await app.auth.listMyInvites();
 
// Or via the proxy (Path B)
const res = await fetch('/api/auth/pluralize/invite-tokens');
const { tokens, quota } = await res.json();

tokens have masked plaintext (********wxyz) plus uses/maxUses/status. Useful for "Invitations: X used / Y remaining" UIs.

Server-enforced caps

Three layers, in order of cost-to-attacker:

  1. Plan entitlementmax_tenant_invites must be > 0 on the tenant's plan. Returns invite_minting_not_allowed (403) otherwise.
  2. Lifetime cap — count of tokens this tenant has minted that still count: active, used, or expired. Tokens explicitly revoked by the tenant via app.auth.revokeInvite(id) do not count, so a regretted mint can be undone. Returns invite_quota_exhausted (403) with { limit, current } once at the cap.
  3. Rate limit — 10 calls per hour per tenant. Shared bucket between mintInvite and revokeInvite so a malicious mint-then-revoke loop is bounded.

Server-forced parameters

Regardless of what the SDK call sends:

  • maxUses is always 1. A tenant invites one specific person per token.
  • expiresInSeconds is capped at 30 days. Default is 7 days when omitted.

These prevent a compromised tenant account from creating a single token with maxUses: 1000000 and broadcasting it.

Revoke an invite (free a slot)

A tenant can revoke an invite they regret minting — for example a typo in the recipient or a "wait, I didn't mean to send that yet" moment. Revoke flips revoked_at, frees one slot of the lifetime cap, and the link stops working immediately.

// Browser SDK (Path A)
await app.auth.revokeInvite(invite.id);
 
// Or via the proxy (Path B)
// app/api/auth/pluralize/invite-tokens/[id]/route.ts
import { auth } from '@/lib/pluralize-auth';
export const DELETE = auth.revokeInvite;
 
// from a client component
await fetch(`/api/auth/pluralize/invite-tokens/${invite.id}`, { method: 'DELETE' });

The response includes the freshly recomputed quota so you can update "X of Y used" UI without a separate listMyInvites round-trip.

Failure modes:

  • invite_not_found (404) — id doesn't exist or belongs to another tenant.
  • invite_already_consumed (409) — the invite was already used to sign up. The slot stays spent.
  • invite_already_revoked (409) — already revoked; calling again is a no-op.

What tenants cannot do

  • Set a custom origin / scope. Every tenant-minted token works for the same app the tenant belongs to.
  • Recover an already-used invite. Once a signup completes against the token, the slot is permanently spent — only a plan change can lift the cap.

Identifying tenant-minted invites

The dashboard's /dashboard/signup invite-tokens table shows an Origin column that distinguishes you (developer-minted) from tenant (which tenant minted it, with their email). This makes audit easy: if a token gets shared somewhere it shouldn't, you can see who originated it.

Login and logout

await app.auth.login('ada@example.com', 'correct horse battery staple');
await app.auth.logout();

Both calls update the session in storage and broadcast to other tabs.

Reacting to session changes

const unsubscribe = app.auth.onChange((user) => {
  if (user) {
    console.log('logged in as', user.email);
  } else {
    console.log('logged out');
  }
});

onChange fires on signup, login, logout, and refresh-token rotation. Call the returned function to unsubscribe.

Full example — login + signed-in UI

Below is an end-to-end Next.js App Router setup: a provider at the root, a login form, and a minimal signed-in header. Drop it into a fresh project and you have working auth.

yourapp.com
A

Ada Lovelace

ada@example.com

Active

1. Provider at the root

// app/layout.tsx
import { PluralizeProvider } from '@pluralize/sdk/react';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <PluralizeProvider
          config={{
            appId: process.env.NEXT_PUBLIC_PLURALIZE_APP_ID!,
            apiKey: process.env.NEXT_PUBLIC_PLURALIZE_API_KEY!,
          }}
        >
          {children}
        </PluralizeProvider>
      </body>
    </html>
  );
}

2. Auth gate

// app/page.tsx
'use client';
import { useAuth } from '@pluralize/sdk/react';
import { LoginForm } from './login-form';
import { Dashboard } from './dashboard';
 
export default function Home() {
  const { user, loading } = useAuth();
  if (loading) return <p className="p-6 text-sm text-muted-foreground">Loading…</p>;
  return user ? <Dashboard /> : <LoginForm />;
}

3. Login form

// app/login-form.tsx
'use client';
import { useState } from 'react';
import { usePluralize } from '@pluralize/sdk/react';
import { PluralizeError } from '@pluralize/sdk';
 
export function LoginForm() {
  const app = usePluralize();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);
 
  async function onSubmit(event: React.FormEvent) {
    event.preventDefault();
    setError(null);
    setSubmitting(true);
    try {
      await app.auth.login(email, password);
    } catch (err) {
      if (err instanceof PluralizeError && err.code === 'invalid_credentials') {
        setError('Wrong email or password.');
      } else {
        setError('Something went wrong. Try again.');
      }
    } finally {
      setSubmitting(false);
    }
  }
 
  return (
    <form onSubmit={onSubmit} className="mx-auto w-full max-w-sm space-y-3 p-6">
      <h1 className="text-lg font-semibold">Welcome back</h1>
 
      <label className="block text-sm">
        Email
        <input
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
      </label>
 
      <label className="block text-sm">
        Password
        <input
          type="password"
          required
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
      </label>
 
      {error && <p className="text-sm text-red-600">{error}</p>}
 
      <button
        type="submit"
        disabled={submitting}
        className="w-full rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
      >
        {submitting ? 'Signing in…' : 'Sign in'}
      </button>
    </form>
  );
}

4. Signed-in header

// app/dashboard.tsx
'use client';
import { useAuth, usePluralize } from '@pluralize/sdk/react';
 
export function Dashboard() {
  const { user } = useAuth();
  const app = usePluralize();
 
  return (
    <header className="flex items-center justify-between border-b px-4 py-3">
      <p className="text-sm font-medium">Signed in as {user?.email}</p>
      <button
        onClick={() => app.auth.logout()}
        className="rounded-md border px-3 py-1 text-sm"
      >
        Sign out
      </button>
    </header>
  );
}

Error reference

Every auth method throws a PluralizeError on failure. The code field is stable enough to branch on in UI code:

| Method | Common code | What it means | | --- | --- | --- | | signup | duplicate_email | An account already exists for this email. | | signup | weak_password | Password did not meet the minimum strength policy. | | login | invalid_credentials | Email or password is wrong. Never say which. | | login, signup | rate_limited | Too many attempts; back off for a minute. | | any authenticated call | session_expired | Refresh failed. Show the login form again. |

try {
  await app.auth.login(email, password);
} catch (err) {
  if (err instanceof PluralizeError) {
    switch (err.code) {
      case 'invalid_credentials':
        return showToast('Wrong email or password.');
      case 'rate_limited':
        return showToast('Too many attempts. Try again in a minute.');
      default:
        return showToast('Something went wrong.');
    }
  }
  throw err;
}