Most integration examples show the happy path and stop. This one is written as middleware you would actually deploy: reusable across routes, failing open on vendor trouble, and applying different thresholds to different actions rather than one global rule.
Everything here is against @sentinelsup/sdk, whose evaluate() takes { token, fingerprintEventId, accountId, email } and returns a decision of allow, review, or block.
The middleware
One factory, parameterised by how strict the route should be. The key never leaves the server, and any vendor failure results in the request continuing.
// middleware/sentinel.js
const Sentinel = require('@sentinelsup/sdk');
const sentinel = new Sentinel({ apiKey: process.env.SENTINEL_KEY });
/**
* @param {object} opts
* @param {'block'|'review'} opts.blockAt — refuse at this band or worse
* @param {boolean} opts.required — 400 if the client sent no token
*/
function guard({ blockAt = 'block', required = false } = {}) {
return async function sentinelGuard(req, res, next) {
const token = req.body?.monocle || req.body?.token;
const fingerprintEventId = req.body?.sentinel_fp || req.body?.fingerprintEventId;
if (!token) {
// An ad blocker or a CSP failure can legitimately stop the collector.
if (required) return res.status(400).json({ error: 'Missing security token' });
req.sentinel = { decision: 'allow', degraded: true };
return next();
}
try {
const verdict = await sentinel.evaluate({
token,
fingerprintEventId,
accountId: req.user?.id, // enables device-to-account linking
email: req.body?.email, // adds email.disposable, checked transiently
});
req.sentinel = verdict;
const refuse = blockAt === 'review'
? verdict.decision !== 'allow'
: verdict.decision === 'block';
if (refuse) {
req.log?.warn({ reasons: verdict.reasons, score: verdict.risk_score }, 'sentinel refused');
return res.status(403).json({ error: 'Request declined' });
}
return next();
} catch (err) {
// Fail open, always. Detection is not availability-critical; your
// signup form is.
req.log?.error({ err }, 'sentinel unavailable');
req.sentinel = { decision: 'allow', degraded: true };
return next();
}
};
}
module.exports = { guard, sentinel };
Applying it by action risk
The point of the factory is that one global threshold is wrong. Reading is cheap; redeeming a promotion is not.
const express = require('express');
const { guard } = require('./middleware/sentinel');
const app = express();
app.use(express.json());
// Public reads: no check at all.
app.get('/api/products', listProducts);
// Signup: block only the worst band, step up on review.
app.post('/api/signup', guard({ blockAt: 'block' }), async (req, res) => {
const stepUp = req.sentinel.decision === 'review';
const user = await createUser(req.body, { requireEmailVerification: stepUp });
res.json({ ok: true, stepUp });
});
// Promo redemption: the strictest gate on the site. Anything not clean is out.
app.post('/api/promo/redeem',
requireAuth,
guard({ blockAt: 'review', required: true }),
redeemPromo
);
// Password change: block, and tell the account owner either way.
app.post('/api/account/password', requireAuth, guard({ blockAt: 'review' }), changePassword);
Why failing open is not a cop-out
It is tempting to treat a failed fraud check as a reason to refuse. Work through the arithmetic before you do.
If the API is unreachable for ten minutes and you fail closed, every signup and checkout in that window fails. If you fail open, some fraction of ten minutes of traffic goes unscreened — and your existing controls, rate limits, and post-hoc review still apply. The second outcome is almost always cheaper, and it is the one your on-call engineer will thank you for at 3am.
The exception is an action that is both irreversible and high-value. For those, failing open is a real risk, and the right answer is usually to queue for manual review rather than to auto-approve or auto-refuse.
Logging the reason, not just the outcome
Store reasons and risk_score alongside your own decision. When someone reports that a legitimate customer was declined, you want to answer from evidence rather than reconstruct it.
The response also carries decision_source and engine_decision when your own rules or allow-pins changed the outcome, which is what tells you whether a bad decision came from the engine or from a rule you wrote.
await db.riskLog.insert({
userId: req.user?.id,
action: 'promo_redeem',
decision: req.sentinel.decision,
engineDecision: req.sentinel.engine_decision ?? req.sentinel.decision,
source: req.sentinel.decision_source ?? 'engine',
score: req.sentinel.risk_score,
reasons: req.sentinel.reasons,
degraded: !!req.sentinel.degraded,
});
Rolling it out without breaking anything
Deploy in monitor mode first: run the middleware, log the verdict, and refuse nothing. After a week you will know your own baseline — what share of real traffic lands in review, which reason codes dominate, whether any of your own staff or QA tooling trips it.
Then enable enforcement on one action, starting with whichever one is costing you money. Promotions usually. Expand from there rather than turning it on everywhere at once, which is how a bad threshold becomes a revenue incident.
Frequently Asked Questions
Should fraud detection middleware fail open or closed?
Open, for almost every action. If the detection API is unreachable and you fail closed, every signup and checkout during the outage fails; if you fail open, a window of traffic goes unscreened while your rate limits and post-hoc review still apply. The second is nearly always the cheaper incident. The exception is actions that are both irreversible and high-value, where the better answer is to queue for manual review rather than auto-approve or auto-refuse.
What if the client never sends a token?
Treat it as degraded rather than hostile by default. An ad blocker, a content-security-policy failure, or a flaky network can legitimately stop the client collector from running, and refusing those users blocks real customers. Mark the request degraded, let it through, and log it. On genuinely high-risk actions such as promotion redemption you can require the token and return 400 without it, because there the cost of a false negative outweighs the occasional false positive.
How do I apply different strictness to different routes?
Make the middleware a factory parameterised by the band at which it refuses, then mount it per route. Public reads need no check at all; signup should block only the worst band and step up on review; promotion redemption and password changes deserve refusing anything that is not clean. A single global threshold is always wrong somewhere, because the cost of a false positive on a product page and on a checkout are not remotely comparable.
How should I roll this out to an existing app?
In monitor mode: run the middleware, record the verdict, and refuse nothing for about a week. That gives you your own baseline — what proportion of genuine traffic lands in review, which reason codes dominate, and whether internal QA or staff tooling trips it. Then enforce on one action, starting with whichever is actually costing money, and expand. Enabling enforcement everywhere at once is how a mis-set threshold becomes a revenue incident.
Drop it into your Express app
One npm install, one middleware, sub-40ms server-side p95.
Try Sentinel free →