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.
Welcome back
Sign in to continue to Recipes
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
localStorageunder a key scoped to yourappId. The SDK refreshes them transparently when a request returns401. - A
BroadcastChannelnamedpluralize: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',
});localeis 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_emailerror you can catch viaPluralizeError. inviteTokenis required when the app's signup mode isinvite_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 only —
inviteTokenrequired. 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:
- Plan entitlement —
max_tenant_invitesmust be > 0 on the tenant's plan. Returnsinvite_minting_not_allowed(403) otherwise. - 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. Returnsinvite_quota_exhausted(403) with{ limit, current }once at the cap. - Rate limit — 10 calls per hour per tenant. Shared bucket between
mintInviteandrevokeInviteso a malicious mint-then-revoke loop is bounded.
Server-forced parameters
Regardless of what the SDK call sends:
maxUsesis always 1. A tenant invites one specific person per token.expiresInSecondsis 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.
Ada Lovelace
ada@example.com
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;
}