All articles Fundamentals

Designing a Signup Policy: Allow, Flag, Verify, Block

Most signup fraud controls start as a single if: if the risk score is high, reject. It works for a week. Then support gets a ticket from a real customer who was refused, someone lowers the threshold, abuse climbs back, and the if grows into a pile of special cases nobody can reason about.

A better starting point is a small, explicit policy with four outcomes and a handful of named inputs. This is the model every Prynt signup recipe (Express, Clerk, Supabase, Auth0) uses, and it works just as well if you write your own.

The four outcomes

From mildest to strictest:

  • Allow: create the account. Nothing to record.
  • Flag: create the account and mark it for review. The user sees nothing different.
  • Verify: create the account, but require an extra step before the valuable part unlocks: a phone number, a card, MFA, a manual approval. Your product decides what “verify” means.
  • Block: refuse the signup.

The two middle outcomes are what make this work. Flag is how you learn without hurting anyone. Verify is how you put a cost on each abusive account while leaving the door open for a real person who happens to look unusual, such as a family sharing one laptop.

The inputs

A signup policy needs only a few facts. With Prynt they all come from one server-side call: the browser sends a requestId with the form and your server fetches GET /v1/events/{requestId} with its secret key.

InputWhere it comes fromWhat it means
Accounts on deviceaccountsOnDevice.countHow many of your accounts have already been linked to this device
Prynt decisiondecision: allow, challenge or blockPrynt’s own verdict from bot, tampering, network and reputation signals
Missing identificationNo requestId, or not foundThe browser never identified: privacy opt-out, blocker, or a direct POST
Reused identificationThe event already has a linkedIdThis requestId already produced a different account
Stale identificationcreatedAt older than your windowThe id was captured long before the submit
Prynt unavailableTimeout or 5xxYou could not check at all
ReasonsreasonCodes on the identify result (the stored event keeps the underlying reasons in risk.reasons)Why: BOT, VPN, MULTI_ACCOUNT, TAMPERING and so on

Reason codes are for logs and reviews, not for the first version of the policy. They tell your analysts why something was flagged, which is what lets you tune later. Our post on the suspect score explains how the score and the codes relate.

Mapping inputs to outcomes

Each input gets exactly one outcome. These are the defaults the Prynt recipes ship with, and they are a reasonable place to start:

RuleDefaultReasoning
Device already has 2+ other accountsblockAllows a personal and a work account; refuses the third
Prynt decision is blockblockConfirmed automation, tampering or a burned device
Prynt decision is challengeflagAmbiguous: worth a look, not worth a refusal
No identificationflagUsually privacy or a blocker, not an attacker
Reused identificationblockSomeone is replaying a previous signup’s id
Prynt unavailableallowFail open: an outage should not lock out real users
Stale identificationflagTreated like missing

When several rules fire, the strictest one wins. A signup that is both “challenge” (flag) and “over the device limit” (block) is blocked. This makes the policy easy to explain: you can always point at the one rule that decided the outcome.

const SEVERITY = { allow: 0, flag: 1, verify: 2, block: 3 };

function decide(ev, { maxOthers = 2 } = {}) {
  if (!ev) return { action: 'flag', reasons: ['missing_request_id'] };
  const hits = [];
  if (ev.linkedId) hits.push(['block', 'request_id_reused']);
  if ((ev.accountsOnDevice?.count ?? 0) >= maxOthers) hits.push(['block', 'device_account_limit']);
  if (ev.decision === 'block') hits.push(['block', 'prynt_block']);
  if (ev.decision === 'challenge') hits.push(['flag', 'prynt_challenge']);
  const action = hits.reduce((a, [h]) => (SEVERITY[h] > SEVERITY[a] ? h : a), 'allow');
  return { action, reasons: hits.map(([, r]) => r) };
}

After the account is created, attach it with PUT /v1/events/{requestId} and { "linkedId": "<your user id>" }. Without that step the device never accumulates accounts and the limit never trips.

Make every rule configurable

Hard-coding the mapping is how policies rot. Expose each rule as a setting so you can change behavior without a deploy. The Prynt recipes do this with environment variables:

PRYNT_MAX_ACCOUNTS_PER_DEVICE=2
PRYNT_ON_LIMIT=block
PRYNT_ON_PRYNT_BLOCK=block
PRYNT_ON_CHALLENGE=flag
PRYNT_ON_MISSING=flag
PRYNT_ON_REPLAY=block
PRYNT_ON_ERROR=allow

That also makes the policy reviewable. Anyone can read it and know what happens to a signup from a device with three accounts.

Start in monitor mode

Do not ship the defaults straight to production. Set every rule to flag for the first one or two weeks:

PRYNT_ON_LIMIT=flag
PRYNT_ON_PRYNT_BLOCK=flag
PRYNT_ON_REPLAY=flag

Every signup goes through, and every account the policy would have acted on carries a flag and its reasons. Then read the flagged list:

  • How many accounts would have been blocked? If it is a large share of signups, either you have a serious abuse problem or the limit is too tight. The reasons tell you which.
  • Who are they? Look at a sample. Are they the throwaway emails and duplicated usage patterns you expected, or real teams on shared machines?
  • What did they do next? Flagged accounts that never activate are cheap. Flagged accounts that burn through your free tier are the ones worth blocking.

Promote one rule at a time from flag to its intended outcome, starting with the one you are most confident about (usually Prynt block). Our guide on rolling out block mode safely covers this staging in more detail.

Where verify fits

Many teams skip verify because it requires building something. It is worth building. For over-limit devices, a phone or card step turns a hard refusal into a choice: the real person sharing a laptop completes it in a minute; the farm has to pay per account. It also gives you a release valve when you are unsure: if a rule’s flagged population looks mostly abusive but not entirely, send it to verify instead of block.

Verify also suits the signals that are informative but not damning. A signup from a datacenter IP with a disposable email and incognito mode is not proof of abuse, but it is a fair reason to ask for a phone number.

Write it down

Finally, keep a one-page description of the policy next to the code: the inputs, the mapping, the reasoning, and the date each rule last changed. When someone asks why a signup was refused, the answer should be a rule name and a reason code, not “the score was high.” The new-user risk scoring guide covers how to log those decisions, and the docs list every field the policy can read.

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