All articles Integration

Auth0 Pre User Registration Action: Enforcing Device Limits on Signup

If your product runs on Auth0, the cleanest place to stop one person from opening account after account is inside Auth0 itself. A Pre User Registration Action runs before the user record exists, which is exactly when you want to ask “how many accounts has this device already made?”

This guide walks through the Prynt Auth0 recipe: a Pre User Registration Action that denies registrations from devices over your limit, and a Post Login Action that attaches the new user_id to the device so the next attempt sees it. Both files ship in the Prynt repository under integrations/auth0 and carry the same shared signup policy as the Express, Clerk and Supabase recipes.

The two-trigger design

The recipe needs two Actions because of one Auth0 constraint: Pre User Registration has no user_id yet.

ActionTriggerJob
pre-user-registration.jsPre User RegistrationFetch the device’s event, deny on block, store the verdict in app_metadata
post-login.jsPost LoginOn first login: re-check, then PUT /v1/events/{requestId} with the user_id. On every login: enforce the stored verdict

Post Login also covers the gap Pre User Registration leaves open. Social and enterprise connections skip Pre User Registration entirely, so their first login is the earliest point you can check them. The same pass catches two signups that raced through in parallel and a requestId reused for a second account.

Step 1: get a requestId into the signup

The device check needs a requestId from the browser. Identify right before you send the user to Universal Login and pass the id on /authorize:

// Load the agent from https://api.pryntid.com/cdn/prynt.umd.js (global Prynt)
const prynt = await Prynt.load({ apiKey: 'pk_live_…' });
const { requestId } = await prynt.identify({ tag: { action: 'signup' } });

await auth0Client.loginWithRedirect({
  authorizationParams: {
    screen_hint: 'signup',
    'ext-prynt_request_id': requestId, // copied into the form by a signup partial
    prynt_request_id: requestId,       // read by Post Login for social signups
  },
});

Wrap the identify call in a try and never block the redirect if it fails; the Actions decide what a missing id means.

How the id reaches Pre User Registration depends on how users sign up:

  • Universal Login (needs a custom domain and page template): install the recipe’s signup prompt partial at form-content-end. It copies the ext- parameter into a ulp-prynt_request_id field, which the Action reads from event.request.body.
  • Your own form posting to /dbconnections/signup: send user_metadata: { prynt_request_id }.
  • Your backend calling the Management API: set app_metadata: { prynt_request_id }.

Step 2: the Pre User Registration Action

Stripped to its core, the Action fetches the event with your secret key, counts the accounts already on the device and denies when the count reaches the limit:

exports.onExecutePreUserRegistration = async (event, api) => {
  const requestId = event.request.body?.['ulp-prynt_request_id']
    || event.user.user_metadata?.prynt_request_id;
  if (!requestId) return; // flag-on-missing is handled by the full recipe

  let ev;
  try {
    const res = await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
      headers: { Authorization: `Bearer ${event.secrets.PRYNT_SECRET_KEY}` },
      signal: AbortSignal.timeout(4000),
    });
    if (!res.ok) return;          // fail open
    ev = await res.json();
  } catch { return; }             // timeout or network error: fail open

  const limit = Number(event.secrets.PRYNT_MAX_ACCOUNTS_PER_DEVICE || 2);
  const others = ev.accountsOnDevice?.count ?? 0;

  if (ev.decision === 'block' || others >= limit) {
    api.access.deny('prynt_device_account_limit',
      "We couldn't create an account from this device. If you think this is a mistake, contact support.");
    return;
  }
  api.user.setAppMetadata('prynt_request_id', requestId);
  api.user.setAppMetadata('prynt', { action: ev.decision === 'challenge' ? 'flag' : 'allow' });
};

The first argument to api.access.deny goes to your tenant logs; the second is what the user sees on the form. Keep the user message neutral and give people a support path, because some shared family computers will hit the limit legitimately.

The full recipe does more than this sketch: it treats an event older than the replay window (15 minutes by default) as missing, refuses a requestId already attached to a different account, and skips the metadata write for passwordless connections, which Auth0 does not allow at this trigger. Use the recipe file rather than this excerpt in production.

Step 3: link the account in Post Login

On the user’s first login the Post Login Action attaches the Auth0 user_id to the device. This is the step that makes the limit work at all: until an account is linked, the device has nothing to count.

exports.onExecutePostLogin = async (event, api) => {
  const am = event.user.app_metadata || {};
  const requestId = am.prynt_request_id || event.request.query?.prynt_request_id;

  if (requestId && !am.prynt?.linkDone) {
    await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${event.secrets.PRYNT_SECRET_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ linkedId: event.user.user_id }),
      signal: AbortSignal.timeout(4000),
    }).catch(() => {});
    api.user.setAppMetadata('prynt', { ...am.prynt, linkDone: true });
  }

  if (am.prynt?.action === 'block') return api.access.deny('Account not available');
  if (am.prynt?.action === 'verify') api.multifactor.enable('any', { allowRememberBrowser: true });
};

Again a simplification: the real Action re-runs the policy for social signups with the new user_id excluded from its own count, and only marks linkDone when the PUT succeeded, so a failed link is retried on the next login.

Secrets belong in the Action, not the app

Add PRYNT_SECRET_KEY (an sk_… key) to each Action with the key icon in the editor. It never appears in your frontend; the browser only ever holds the public pk_… key. A secret key sees only its own environment, so use a test key in your Auth0 development tenant and a live key in production.

The policy itself is configured through more secrets, identical in both Actions:

  • PRYNT_MAX_ACCOUNTS_PER_DEVICE, default 2: the limit trips when the device already holds that many other accounts.
  • PRYNT_ON_LIMIT, PRYNT_ON_PRYNT_BLOCK, PRYNT_ON_CHALLENGE, PRYNT_ON_MISSING, PRYNT_ON_REPLAY, PRYNT_ON_ERROR: one of allow, flag, verify or block per rule (defaults: block, block, flag, flag, block, allow).
  • PRYNT_MAX_EVENT_AGE_SEC, default 900: identifications older than this are treated as missing.

If PRYNT_SECRET_KEY is missing, both Actions log the problem and allow the signup rather than breaking registration for everyone.

Flag first, block later

Blocking at the registration form is satisfying, but it is the least reversible choice you can make. A gentler rollout:

  1. Set PRYNT_ON_LIMIT=flag for the first couple of weeks. Every signup goes through, and over-limit users get app_metadata.prynt.action = 'flag'. Search the dashboard for app_metadata.prynt.action:"flag" and look at who they are.
  2. Move to verify once the flagged population looks like what you expected. The account is created, but Post Login enforces MFA on every login. That is close to free for a real user and a real per-account cost for a farm.
  3. Reserve block for products where every extra account costs you money directly, such as trials that grant compute or credits.

To gate features in your app rather than at login, expose the verdict as a custom claim in Post Login with api.idToken.setCustomClaim and read it where you provision the trial.

The same staged approach is described in more depth in our guide to one account per person, and the broader picture of signup fraud and SaaS account security explains where device limits sit next to email and payment checks.

Testing it

Sign up twice from one browser with different email addresses. With the default limit of 2, the third attempt is refused on the form. Then sign in with a social connection from the same browser: that user is created by Auth0 but denied at first login. The recipe’s own test file drives both Actions with mocked event and api objects; run it with node --test integrations/auth0/.

The device check runs on the free plan, so you can wire the whole flow against your development tenant before deciding anything. The docs list every field on the event, and the SDK page covers the server libraries if you later move the check out of Auth0 and into your own backend.

Try it free

Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.

Keep reading