Guide · Express 5 + Node 18+

In Express, it is middleware.
It was always going to be middleware.

One function with the (req, res, next) signature, passed to the signup route before the handler. What deserves a page are the Express 5 specifics: req.body is undefined without a parser, rejected promises reach the error handler on their own, and the fail-open path has to distinguish an outage from a verdict.

01The middleware

// middleware/verifyHuman.js — Express 5, Node 18+
const VERIFY_URL = 'https://humaverify.com/api/v1/verify';

async function verifyHuman(req, res, next) {
  // Express 5: req.body is undefined (not {}) when no body parser ran.
  const { email, sessionData } = req.body ?? {};
  try {
    const r = await fetch(VERIFY_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.HUMA_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ userId: email, sessionData }),
      signal: AbortSignal.timeout(3000), // throws TimeoutError past 3s
    });
    if (!r.ok) {
      // A 401 here means a misconfigured key, not a bot. Fail open,
      // but never invisibly.
      console.warn('useHUMA verify returned', r.status, '- failing open');
      return next();
    }
    const verdict = await r.json();
    if (!verdict.human || verdict.agent?.is_agent) {
      return res.status(403).json({ error: 'verification_failed' });
    }
    req.humaVerdict = verdict; // the handler can store verdict.token
    return next();
  } catch (err) {
    // TimeoutError or network failure: our outage is not your outage.
    return next();
  }
}

module.exports = { verifyHuman };

02The route

// app.js
const express = require('express');
const { verifyHuman } = require('./middleware/verifyHuman');

const app = express();
app.use(express.json()); // without this, req.body is undefined in Express 5

app.post('/signup', verifyHuman, async (req, res) => {
  // Express 5 forwards a rejected promise here to the error handler
  // automatically, as if next(err) were called. No try/catch boilerplate.
  const user = await createUser(req.body);
  res.status(201).json({ id: user.id });
});

03The client sends the signals

<!-- the signup page -->
<script src="https://humaverify.com/huma.js"></script>
<script>
  async function signup(email, password) {
    const res = await fetch('/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email,
        password,
        sessionData: window.Huma ? window.Huma.debug().features : undefined,
      }),
    });
    if (res.status === 403) {
      // show the "we could not verify this session" state
    }
  }
</script>

For a classic HTML form post instead of fetch, put the JSON string in a hidden field and mount express.urlencoded(). Note its default changed in Express 5: extended is now false.

What bites

Why this instead of a CAPTCHA

A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.

Next.jsWordPressClerkAuth0Supabase AuthAuth.js / NextAuthFirebase Auth

Express for the server, useHUMA for humanity.

Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.

Get your API key →

The Express 5 behaviours on this page (body parsing, async error forwarding, the urlencoded default) were checked against the official migration guide and documentation on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.

Help