By the end of this you will have an Express signup fraud middleware called screenSignup() that runs before your /signup handler. It returns a 403 on a high-confidence block, attaches the verdict to req on everything else, and never takes your signup form down when the abuse check is slow or unreachable.
That last property is the one people get wrong. An abuse check sits directly in the path of the thing your business needs most, and a synchronous third-party call in that position is a new single point of failure unless you decide, deliberately, what happens when it times out.
Prerequisites
- Node 18.17 or newer, Express 4 or 5, and a
/signuproute that creates a user. - A Portreeve account with a test secret key (
sk_test_...) and a publishable key (pk_test_...). The free tier is 1,000 screened events a month with no card. - Somewhere to store two columns per signup: the screening event id and the verdict.
- If you serve traffic through a load balancer, CDN, or platform router, you need to know how many proxies sit in front of your app. Step 1 covers finding that number.
1. Install, keys, and trust proxy
npm install portreeve
The server SDK has zero runtime dependencies. Put the secret key in the environment and never in the browser bundle:
PORTREEVE_SECRET_KEY=sk_test_...
Now the part that decides whether any of this works: req.ip.
Express derives req.ip from the socket address unless you tell it otherwise. Behind a proxy the socket address is your load balancer, so every signup in the world arrives from the same three addresses and per-IP velocity counting becomes noise. The Express guide on proxies says req.ip and req.ips are "populated based on the socket address and X-Forwarded-For header, starting at the first untrusted address."
Set the hop count, not true:
app.set("trust proxy", 1); // one proxy between the client and this process
With a number, Express counts back from your socket, right to left through X-Forwarded-For. With true it takes the leftmost entry instead, and the leftmost entry is the part a client wrote. express-rate-limit logs a dedicated error code when it sees that setting, ERR_ERL_PERMISSIVE_TRUST_PROXY, because the setting "allows anyone to trivially bypass IP-based rate limiting."
To find your number, mount a throwaway route that returns req.ip, hit it from a phone on cell data, and increment until the response matches your real address. That beats guessing from a deployment diagram, which is usually a hop out of date.
On Cloudflare there is a shortcut. Cloudflare's header reference says CF-Connecting-IP "provides the client IP address connecting to Cloudflare to the origin web server", and Cloudflare appends to X-Forwarded-For rather than replacing it. Reading req.get("cf-connecting-ip") with req.ip as the fallback is more stable than counting hops through a chain you do not control.
2. The middleware: screen before the account exists
The middleware runs before the user row is written, which raises an immediate question: what do you send as external_user_id when there is no user yet?
Mint the id yourself. Generate it in the middleware, screen with it, then create the account with that same id. You get a stable key for the verdict, for later feedback, and for deduplicating a double-submitted form.
// middleware/screen-signup.js
import { randomUUID } from "node:crypto";
import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY);
export function screenSignup({ failClosed = false } = {}) {
return async function screen(req, res, next) {
const externalUserId = randomUUID();
const result = await portreeve.verdict({
event_type: "signup",
external_user_id: externalUserId,
ip: req.get("cf-connecting-ip") ?? req.ip,
email: req.body.email,
device_token: req.body.device_token,
});
if (result.verdict === "block") {
return res.status(403).json({ error: "signup_unavailable" });
}
if (failClosed && result.degraded) {
return res.status(503).json({ error: "retry" });
}
req.screening = { ...result, externalUserId };
return next();
};
}
Three things about that function are load-bearing.
There is no try/catch. The SDK fails open by default: on a timeout or an outage it returns allow with degraded: true and a *_failopen reason code, inside a 400 ms budget. Wrapping the call in a catch that swallows errors would only hide the flag you want to read.
review does not stop the request. The flow proceeds, the event lands in a review queue in the dashboard, and if a human denies it later a signed webhook reaches your server so you can revoke the account. That is what makes an aggressive engine safe to run in front of a signup form: review costs the end user nothing, so block can be reserved for signals you would bet money on. The details are in the handling verdicts reference.
device_token is optional and worth wiring anyway. The device fingerprint is one of the hashed identity keys accounts get linked on, so without it a farm driving one browser through fifty addresses looks like fifty unrelated people. It matters again at checkout: two block-strength card rules read a device-keyed counter, and without a token those shapes top out at review. The client side is one call:
import { collectDeviceToken } from "@portreeve/browser";
const device_token = await collectDeviceToken("pk_test_...");
// resolves to a token or null, never throws; tokens live 15 minutes
Post it with the signup form. A missing, malformed, or expired token is silently dropped and the verdict still returns 200, so there is no failure mode to code around. The device fingerprinting guide covers where to place the call in a single-page app.
3. Wire it on the route and persist the event id
import express from "express";
import { screenSignup } from "./middleware/screen-signup.js";
const app = express();
app.set("trust proxy", 1);
app.use(express.json());
app.post("/signup", screenSignup(), async (req, res) => {
const { id, verdict, reasons, degraded, externalUserId } = req.screening;
const user = await db.users.create({
id: externalUserId,
email: req.body.email,
review_pending: verdict === "review",
});
if (!degraded) {
await db.screenings.create({ user_id: user.id, event_id: id, verdict, reasons });
}
res.status(201).json({ id: user.id });
});
The if (!degraded) guard matters more than it looks. A degraded verdict is a placeholder rather than a judgement, because nothing was evaluated. Store it unguarded and you get a table full of rows that say allow for accounts nobody ever screened, and six months later someone builds a dashboard on that column and reports a suspiciously clean funnel.
Keep the row for the account itself. review_pending is what your product reads to hold a feature, cap a quota, or delay an outbound email while the queue works through it.
4. Test mode, and seeing the event land
Test mode is fully isolated from live, so you can fire real traffic at it without touching your live counters. Start the server with sk_test_... and post a signup that should trip something obvious:
curl -X POST http://localhost:3000/signup \
-H 'content-type: application/json' \
-d '{"email":"[email protected]"}'
What to expect: a 201, and a disposable-email reason code in req.screening.reasons. The verdict itself depends on what else the engine has seen from that device and IP. Open the dashboard in test mode and the event is there with its reason codes and the identity keys it linked on. If you see nothing, the key is live rather than test, or the request never reached the middleware.
Then close the loop. When you confirm an account was abusive, say so, because confirmed abuse on one account marks the whole linked cluster:
await portreeve.feedback(eventId, "confirmed_abuse");
// or address the account rather than a single event
await portreeve.feedback({ kind: "external_user_id", value: userId }, "chargeback", "stripe dispute du_...");
Feedback is idempotent per event and outcome, so a retried worker cannot double-count. Test mode and feedback has the full loop.
When Express.js fraud detection should fail closed
Signup and checkout deserve different answers to the same outage.
A signup you cannot screen is a signup you can review tomorrow. A card authorization you cannot screen is money moving now, and a card tester probing your /checkout endpoint during a five-minute degradation will happily take the free window. That is the case for failing closed on payment routes only, which is why the factory takes a flag rather than hard-coding one policy:
app.post("/signup", screenSignup(), createAccount);
app.post("/checkout", screenCheckout({ failClosed: true }), takePayment);
screenCheckout() is the same function body with event_type: "checkout_attempt" and a payment: { card_fingerprint, card_funding, amount, currency } block read off the request, amount in minor units. Everything else, including the 403 on block and the degraded check, is identical.
A 503 on checkout during a degradation is a bad minute. An unscreened card testing run is a bad month of chargebacks. Pick per route, and write the reason in a comment so the next person does not "fix" the inconsistency.
Where Node signup abuse prevention goes wrong
Sending server IPs as client IPs. This is the quiet one, because nothing errors. Velocity counters fill with your load balancer's address, every account looks like it shares an IP, and the signal degrades to noise. Related: some proxies write IP:PORT into X-Forwarded-For, and as @ngosset put it when reporting that against express-rate-limit, "a user can simply close and re-open their browser to bypass the rate-limit timer as their source port of their HTTP Request will change." IP is a soft signal for good reasons; shared egress makes per-IP counts untrustworthy, so it never links accounts on its own. Device and email keys do the linking. There is more on how those counters are built in velocity checks.
Treating review as a block. The most common integration bug is a route that does if (verdict !== "allow") return res.status(403). That converts a free, reversible flag into a lost customer, and it destroys the reason the engine can afford to flag aggressively in the first place. block is a 403. review is a database column.
Retrying without a key. Users double-tap submit, mobile clients retry on flaky networks, and load balancers re-dispatch. Without a stable identifier you get two verdicts, two accounts, and two rows that look like a velocity spike caused by your own retry logic. Minting externalUserId in the middleware is half the fix; the other half is deduplicating the signup POST itself. If your handler also creates a Stripe customer, pass that same id as the Idempotency-Key header: Stripe saves "the resulting status code and body of the first request made for any given idempotency key" and prunes keys only once they are at least 24 hours old. Apply the same rule to your review webhook handler: deliveries retry until they get a 2xx, so a handler that revokes an account must tolerate being called twice.
Before you switch to live keys
Swap sk_test_ for sk_live_ and pk_test_ for pk_live_, and confirm your hop count is right in production rather than in staging, where the proxy chain is usually shorter.
Add the other four moments once signup is stable. The same factory covers login, trial_start, trial_convert, and checkout_attempt with a different event_type, and login gets an account baseline that flags an unrecognized device against the account's own history. Free tier abuse covers which of those moments matter most when the product has a generous free plan.
Then leave it alone for a week and read the review queue before tuning anything. The queue tells you what your abuse looks like, which is rarely what you assumed when you wrote the middleware.
You can get a test key and have this running locally in about ten minutes: create a free account, no card required.