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 an email_taken 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.

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, continueWithX

4. 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 account

Email 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 language

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

yourapp.com
A

Ada Lovelace

ada@example.com

Active

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;
}