Tutorials

Check signup IPs in Express middleware

An address that belongs to a cloud host or the Tor network tells you something about a signup before any form field does. This tutorial adds an Express middleware that looks up the caller's IP, attaches the answer to the request, and gets out of the way when the lookup fails. Most of the care goes into reading the client address correctly, because behind a proxy the obvious value is wrong.

By LookValid engineering. Published 2026-10-19.

What the middleware promises

A middleware function can run code, change the request object, end the request or call the next function, and if it does not end the request it must call next() or the request hangs 2. Ours takes the second route: it attaches an ipCheck object to the request and always calls next(). A signup should never fail because a lookup was slow, so every error path ends in the same place, with the status set to error and the request carrying on.

Get the client address right

Behind a reverse proxy, the socket address your app sees is the proxy, not the visitor. Express documents that its address APIs may then show the proxy's internal address, and its trust proxy setting tells it which proxies to believe 1. With the default of false, the address comes from the socket. With a number n, Express uses the address n hops away, reading X-Forwarded-For from right to left 1.

Common trust proxy values and what they do
SettingEffect on req.ip
false (default)The socket address, the proxy itself when one is in front
1The address one hop from the app, correct for a single trusted proxy
trueThe left-most X-Forwarded-For entry, which a client can forge unless the last proxy overwrites the header
loopback, linklocal, uniquelocalNamed ranges to treat as proxies and skip over

Common trust proxy values and what they do

Express reads X-Forwarded-For. Some proxies send the standardized Forwarded header instead. Its for parameter names the client, an IPv6 address inside it is quoted and wrapped in square brackets, and a port may follow 3. Each proxy adds its own element to the list, and the standard warns that the header cannot be relied on, because any node on the path can change it, so the part added before a trusted proxy cannot be trusted 3. Reading that together, with one trusted proxy the element to use is the last one. The helper below does exactly that, and it is off unless you pass trustForwarded.

The middleware

Save this as ip-check.js. It uses the built-in fetch, so it has no dependencies beyond Express itself.

import { isIP } from 'node:net';
​
const ENDPOINT = 'https://YOUR_API_HOST/v1/ip';
const REMEMBER_MS = 60 * 60 * 1000;
const MAX_ENTRIES = 5000;
const remembered = new Map();
​
// The last element is the one your own proxy appended. Earlier ones may come from the client.
export function forwardedFor(header) {
  if (!header) return null;
  const elements = header.split(',');
  const match = /(?:^|;)\s*for=("?)([^;"]+)\1/i.exec(elements[elements.length - 1]);
  if (!match) return null;
  let node = match[2];
  if (node.startsWith('[')) node = node.slice(1, node.indexOf(']'));
  else if (node.includes(':')) node = node.slice(0, node.indexOf(':'));
  return isIP(node) ? node : null;
}
​
function remember(ip, value) {
  if (remembered.size >= MAX_ENTRIES) remembered.delete(remembered.keys().next().value);
  remembered.set(ip, { value, until: Date.now() + REMEMBER_MS });
}
​
export function ipCheck({ apiKey, timeoutMs = 800, trustForwarded = false }) {
  return async function ipCheckMiddleware(req, res, next) {
    const ip = (trustForwarded ? forwardedFor(req.headers.forwarded) : null) ?? req.ip;
    req.ipCheck = { ip, status: 'skipped' };
    if (!ip) return next();
​
    const hit = remembered.get(ip);
    if (hit && hit.until > Date.now()) {
      req.ipCheck = hit.value;
      return next();
    }
    try {
      const response = await fetch(ENDPOINT, {
        method: 'POST',
        headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ ip }),
        signal: AbortSignal.timeout(timeoutMs),
      });
      if (response.status === 200) {
        const body = await response.json();
        const data = body.data ?? {};
        req.ipCheck = {
          ip,
          status: body.found ? 'found' : 'not_found',
          country: data.country ?? null,
          isHosting: data.is_hosting ?? null,
          riskScore: data.risk_score ?? null,
        };
        remember(ip, req.ipCheck);
      } else if (response.status === 400) {
        remember(ip, req.ipCheck); // a private or reserved address: skipped, and not worth asking again
      } else {
        req.ipCheck = { ip, status: 'error', http: response.status };
      }
    } catch {
      req.ipCheck = { ip, status: 'error' };
    }
    next(); // always: the lookup informs the request, it never blocks it
  };
}

ip-check.js

Wire it onto the signup route, and set trust proxy to match your real number of proxies:

import express from 'express';
import { ipCheck } from './ip-check.js';
​
const app = express();
app.set('trust proxy', 1); // one reverse proxy in front of this app
​
app.post('/signup', ipCheck({ apiKey: process.env.LOOKVALID_API_KEY }), (req, res) => {
  const { status, isHosting, riskScore } = req.ipCheck;
  const review = status === 'found' && (isHosting === true || riskScore >= 50);
  // Save the account either way; send the flagged ones to your review queue.
  res.status(201).json({ ok: true, review });
});

Using it in a route

Caching, timeouts and failing open

  • Timeout: 800 ms by default. Pick a number well below what a signup can afford to wait, and lower it if your form is latency sensitive.
  • Local memory cache: one hour per address, capped at 5,000 entries so a flood of distinct addresses cannot grow memory without limit. This saves round trips. It does not save credits, because LookValid already serves a repeat lookup from your account's cache for 7 days at no charge.
  • Private and reserved addresses, such as 127.0.0.1 in development, are refused with HTTP 400 at no charge. The middleware records them as skipped rather than as errors.
  • Failing open: any timeout, network error or unexpected status leaves ipCheck.status as error. Log it, but never reject the signup because of it.
  • IPv4 addresses written in IPv6 form, such as the mapped addresses a dual-stack server reports, are read as the IPv4 address they carry.

Use the result and watch the cost

Treat the result as one signal among several. A cloud-hosted address is normal for a developer on a VPS and unusual for a shopper, so the sample route above only flags a signup for review and still creates the account. The cut-off of 50 is a placeholder, so choose yours from your own data. A found lookup costs 1 credit, and misses, refusals and timeouts cost nothing.

Add the middleware to one route, log req.ipCheck for a week, and then decide what to do with it. The IP product page describes every field, and the quickstart shows how to get a key.

Sources

  1. Express guide: Express behind proxies, read 2026-10-19
  2. Express guide: Writing middleware for use in Express apps, read 2026-10-19
  3. RFC 7239: Forwarded HTTP Extension, read 2026-10-19