All articles Integration

An Express Middleware for Signup and Trial Abuse in Node.js

Most signup abuse in a Node.js product comes down to one device opening account after account to collect another free trial, another batch of credits or another referral bonus. You can stop it at the one place every account passes through: the POST /signup route. In Express that means a middleware.

This guide covers the middleware from the Prynt integrations/express recipe and how it works, then shows a hand-rolled version on @prynt/node for teams who want their own control flow.

The flow in three steps

  1. Browser: identify the visitor on the signup page and send the requestId with the form.
  2. Server, before creating the user: fetch the event with your secret key and check decision and accountsOnDevice.
  3. Server, after creating the user: attach your user id to the event as linkedId, so the next signup from this device counts this account.

The browser only collects. All the decisions happen on the server, where nobody can edit the code.

The signup page

Identify when the page loads, so the requestId is ready by the time the user clicks submit:

<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
  const ready = Prynt.load({ apiKey: 'pk_live_…' })
    .then((agent) => agent.identify({ tag: { action: 'signup' } }))
    .catch(() => null); // never block the form on a client-side failure

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const ident = await ready;
    await fetch('/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password, pryntRequestId: ident && ident.requestId }),
    });
  });
</script>

The .catch(() => null) matters. When an ad blocker or a network hiccup stops the agent, the form still submits. The server then sees a missing requestId and handles it under the policy, as covered below.

Mounting the guard

Copy prynt-signup-guard.mjs and lib/prynt-signup-policy.mjs from the recipe into your project, then mount the guard after the body parser:

import express from 'express';
import { pryntSignupGuard } from './prynt-signup-guard.mjs';

app.post('/signup', express.json(), pryntSignupGuard(), async (req, res) => {
  const user = await db.users.create({ ...req.body, prynt: req.prynt.metadata });
  await req.prynt.attach(user.id);            // PUT /v1/events/:requestId { linkedId }
  res.status(201).json({
    ok: true,
    requireVerification: req.prynt.verdict.action === 'verify',
  });
});

The guard reads req.body.pryntRequestId (or an X-Prynt-Request-Id header) and calls GET /v1/events/{requestId} with PRYNT_SECRET_KEY. It then runs the shared policy. On block it returns 403 with a signup_blocked error. In every other case it calls next() and sets req.prynt = { verdict, metadata, attach }.

The guard is plain (req, res, next), so it also works with Connect, Polka and Nest’s Express adapter.

The policy module

Every Prynt recipe (Express, Clerk, Supabase, Auth0) uses one shared policy. It reduces an event to one of four actions, from mildest to strictest: allow, flag, verify, block. When several rules match, the strictest one wins.

SituationDefaultReason code
Device already holds 2+ other accountsblockdevice_account_limit
Prynt decision is blockblockprynt_block
requestId already attached to another accountblockrequest_id_reused
Prynt decision is challengeflagprynt_challenge
No, unknown or stale requestIdflagmissing_request_id, event_not_found, stale_event
Prynt unreachableallowprynt_unavailable

You configure it with environment variables: PRYNT_MAX_ACCOUNTS_PER_DEVICE, PRYNT_ON_LIMIT, PRYNT_ON_CHALLENGE, PRYNT_ON_MISSING, PRYNT_ON_ERROR and the rest. You can also pass pryntSignupGuard({ policy: { maxAccountsPerDevice: 3, onLimit: 'verify' } }) in code. The guard validates the policy once at startup, so a typo in an action name crashes the boot instead of your first signup.

Two defaults are worth knowing. PRYNT_MAX_EVENT_AGE_SEC (900 by default) treats old identifications as missing. A requestId captured an hour earlier and replayed later doesn’t get a clean pass. And the replay rule blocks a requestId that’s already attached to a different account, because someone is reusing one clean identification for many signups.

Choosing block, verify or flag

  • block fits products where an extra account costs real money: GPU minutes, LLM credits, SMS sends. Shared family computers and office machines can trip the limit, so give support a way to let a real person through.
  • verify creates the account but holds back the trial until the user does something that costs an abuser per account, such as a phone OTP or a card on file. For B2C this is usually the best default.
  • flag changes nothing for the user. It stores req.prynt.metadata so you can review the account later.

A sensible rollout is a week with PRYNT_ON_LIMIT=flag to measure how much abuse you have. Then switch to verify or block once you’ve read the flagged accounts. How to tune fraud thresholds explains how to pick the limit from your own data.

Rolling your own with @prynt/node

If you’d rather write the logic yourself, the Node SDK covers the same two calls:

import { PryntServer } from '@prynt/node';
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY });

export async function signupGuard(req, res, next) {
  const requestId = req.body?.pryntRequestId;
  req.prynt = { requestId, flagged: false };
  if (!requestId) { req.prynt.flagged = true; return next(); }

  try {
    const event = await prynt.getEvent(requestId);
    const others = event.accountsOnDevice?.count ?? 0;
    if (event.decision === 'block' || others >= 2) {
      return res.status(403).json({ error: { code: 'signup_blocked' } });
    }
    req.prynt.flagged = event.decision === 'challenge';
    req.prynt.visitorId = event.visitorId;
  } catch (err) {
    req.prynt.flagged = true; // outage or unknown id: let the signup through, review later
  }
  next();
}

// in the handler, after the user row exists:
// await prynt.updateEvent(req.prynt.requestId, { linkedId: user.id });

accountsOnDevice.count is the number of your accounts (linkedIds) already seen on that device. The list is capped. truncated: true means more exist than were returned, so treat it as at least the listed number. The Node server-side verification guide covers the rest of the event fields, including smartSignals and riskScore.

Don’t skip the attach step

The most common integration bug is forgetting updateEvent. Without it, Prynt never learns which account belongs to which device, accountsOnDevice stays at zero, and the limit never trips. Await the call after the user row is committed. It’s quick, and awaiting it means a fast second signup from the same device already sees the first.

Keep the order exact: check, create, attach. If you attach before the user row is committed and the insert then fails, the device now carries a phantom account that counts against it.

Rate limits still help

A device limit and a rate limit answer different questions. The device limit asks how many accounts this device already has. A rate limit asks how fast requests arrive. Keep a coarse rate limit on /signup for raw floods, and key it by device where you can. Rate limiting by device shows how to combine the two, and device-based signup limits covers picking the right number per product.

Wrap-up

Install the guard after your body parser and start with flag. Always attach the linkedId after the user exists. Once the flagged list confirms what you suspected, turn on verify or block. The docs list every event field, and the Free plan covers enough identifications to run this on a real signup page.

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