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 an
email_takenerror 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.
Social login
Pluralize handles OAuth for seven providers — Google, GitHub, Apple, Microsoft, Facebook, Discord, and X — so a tenant can sign in without a password. The session you get back is identical to email/password signup; the tenant is anchored on the verified email the provider returns.
1. Configure the provider. In Dashboard → Settings → OAuth, enable a provider and
paste its client id and secret. The dashboard shows the exact callback URL to register on
the provider's side. (X requires the "Request email from users" permission — Pluralize
needs a verified email to create the tenant, and returns oauth_email_unavailable
without one.)
2. Allowlist your redirect origin. Add the origin you redirect back to in Dashboard
→ Settings → CORS — the redirect URL's origin must be allowed.
3. Start the flow from the SDK. Each provider has a continueWith* method. They
navigate the browser away, so there's nothing to await:
app.auth.continueWithGoogle({ redirect: 'https://yourapp.example/auth/callback' });
// also: continueWithGitHub, continueWithApple, continueWithMicrosoft,
// continueWithFacebook, continueWithDiscord, continueWithX4. Receive the session. After the provider approves, Pluralize redirects the browser
to your redirect URL with the freshly-minted tokens in the URL fragment. When the SDK
initializes on that page it reads them, stores the session, strips the fragment, and fires
onAuthChange. So your callback page can be the page that already initializes the SDK:
// app/auth/callback/page.tsx
'use client';
import { useCurrentUser } from '@/lib/use-current-user';
export default function Callback() {
const { user, loading } = useCurrentUser();
if (loading) return <p>Signing you in…</p>;
return user ? <RedirectHome /> : <p>Sign-in failed. <a href="/login">Try again</a>.</p>;
}For an invite_only app, forward the invite through the same call so OAuth signups are
gated too — the token survives the provider round-trip in a signed state cookie:
const invite = new URLSearchParams(location.search).get('invite') ?? undefined;
app.auth.continueWithGitHub({
redirect: location.origin + '/auth/callback',
inviteToken: invite,
});Password reset
Password reset is a two-step flow over the REST API. It is the one auth flow without an
SDK wrapper today, so you call it with fetch. Both calls authenticate with your
publishable x-pluralize-key — the access token isn't involved, because the user is by
definition locked out.
Step 1 — request a reset. Pluralize emails the tenant a branded link to the
reset_password_url you configured (Dashboard → Settings; supports a {locale}
placeholder), with the token appended as ?token=....
await fetch('https://pluralize.app/api/v1/auth/request-password-reset', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-pluralize-key': process.env.NEXT_PUBLIC_PLURALIZE_API_KEY!,
},
body: JSON.stringify({ email }),
});
// Always resolves 204 — the response never reveals whether the email exists.Step 2 — set the new password. Your reset page reads token from the query string and
submits it with the new password (10–128 characters):
const res = await fetch('https://pluralize.app/api/v1/auth/reset-password', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-pluralize-key': process.env.NEXT_PUBLIC_PLURALIZE_API_KEY!,
},
body: JSON.stringify({ token, password: newPassword }),
});
if (!res.ok) {
const { error } = await res.json();
// invalid_token | token_expired | token_consumed | weak_password
}On success, every existing session for that tenant is revoked, so a password-leak attacker is signed out everywhere. The token is single-use and valid for one hour.
Bring your own email. To send the reset email from your own ESP with your own template,
pass sendEmail: false to step 1. Instead of mailing, Pluralize returns the plaintext
token so you build the link yourself (run this from your server so the token never reaches
the browser):
const res = await fetch('https://pluralize.app/api/v1/auth/request-password-reset', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-pluralize-key': key },
body: JSON.stringify({ email, sendEmail: false }),
});
const { resetToken } = await res.json(); // null when the email has no accountEmail verification
New tenants are usable immediately — they don't have to verify first — but the signup email
includes a verification link so you can gate sensitive actions on a confirmed address.
Pluralize sends that email and points the link at the verify_email_url you configure;
clicking it marks the tenant verified and redirects back with ?status=verified (or
?status=invalid&reason=...).
For a "didn't get the email?" button, the SDK has a one-liner (it uses the signed-in session, so call it after login):
await app.auth.resendVerification(); // re-sends in the tenant's locale
await app.auth.resendVerification({ locale: 'es' }); // or override the languageIf you host your own redemption page instead of using the emailed link, POST the token yourself:
await fetch('https://pluralize.app/api/v1/auth/verify-email', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-pluralize-key': key },
body: JSON.stringify({ token }), // from your page's ?token= param
});Read tenant.emailVerifiedAt (on the session / from me) to decide whether to show a
"please verify" banner — the migration guide has a full banner example.
Reacting to session changes
const unsubscribe = app.auth.onAuthChange((user) => {
if (user) {
console.log('logged in as', user.email);
} else {
console.log('logged out');
}
});onAuthChange fires on signup, login, logout, and after a background token refresh. 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. SDK singleton + session hook
The SDK has no Provider — you initialize once at the module level and share the
instance via plain import. Pair it with a tiny useCurrentUser hook for React
reactivity. (See Quickstart for the
hook source.)
// lib/pluralize.ts
import { Pluralize } from '@pluralize/sdk';
export const app = Pluralize.init({
appId: process.env.NEXT_PUBLIC_PLURALIZE_APP_ID!,
apiKey: process.env.NEXT_PUBLIC_PLURALIZE_API_KEY!,
});2. Auth gate
// app/page.tsx
'use client';
import { useCurrentUser } from '@/lib/use-current-user';
import { LoginForm } from './login-form';
import { Dashboard } from './dashboard';
export default function Home() {
const { user, loading } = useCurrentUser();
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 { app } from '@/lib/pluralize';
import { PluralizeError } from '@pluralize/sdk';
export function LoginForm() {
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 { app } from '@/lib/pluralize';
import { useCurrentUser } from '@/lib/use-current-user';
export function Dashboard() {
const { user } = useCurrentUser();
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 | email_taken | 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;
}