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
req.bodyisundefinedin Express 5 when no body parser ran, where v4 gave{}. Destructure fromreq.body ?? {}or a content-type-less request throws inside your middleware.- A 4xx from the verify API is not a bot verdict. A misconfigured key returns 401, and treating that as "block everyone" is how a config typo becomes an outage of your signup. Fail open on non-2xx, with a log line.
AbortSignal.timeout()needs Node 17.3+, stable in 18. On older Node use an AbortController with setTimeout.express.urlencoded()defaults toextended: falsein Express 5, a change from v4. Only relevant for the hidden-field variant.- Store
req.humaVerdict.tokenwith the account you create. It is the receipt for this verification, and it is how you audit later.
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.
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.