Determine a request came from the proxy
Goal
Reject traffic that hits your origin without going through the pool. When Origin verification is enabled, nthbouncer signs every forwarded request; your origin checks that signature before doing work.
Reference for headers and limits: Access → Origin verification.
Enable signing
- Open Pools → Access → Origin verification.
- Enable it and copy the
osk_…secret once. - Store it as
NTHPOOL_ORIGIN_SIGNING_SECRET(or equivalent) on the origin. - Deploy verification that reads the raw body bytes before JSON parsers or decompressors rewrite them.
The dashboard pool page also has language-specific verify snippets under the examples panel.
What to verify
On each inbound request at the origin:
- Require
X-Nthpool-Timestamp,X-Nthpool-Nonce, andX-Nthpool-Signature. - Reject if the timestamp is outside ±300 seconds.
- Let
nbe the raw body length. IfContent-Lengthis present, reject when it does not equaln. - Build the outbound canonical string (fields joined with
\n):
METHOD
PATH
QUERY
SHA256(BODY_PREFIX)
CONTENT_LENGTH
TIMESTAMP
NONCEBODY_PREFIX is body[0 .. min(n, 1 MiB)). CONTENT_LENGTH is the decimal
string of n (0 for empty / GET / HEAD).
- HMAC-SHA256 with the
osk_…secret; hex-encode; compare in constant time.
PATH and QUERY must match what the origin received (query without ?).
This is not the inbound caller HMAC scheme (that hashes the whole body and
omits CONTENT_LENGTH).
Node example
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
const MAX_SKEW_SECONDS = 300;
const PREFIX_BYTES = 1024 * 1024; // 1 MiB
export function cameThroughNthpool(req, rawBody) {
const timestamp = req.headers['x-nthpool-timestamp'];
const nonce = req.headers['x-nthpool-nonce'];
const signature = req.headers['x-nthpool-signature'];
if (!timestamp || !nonce || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) {
return false;
}
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody ?? '');
const declared = req.headers['content-length'];
if (declared !== undefined && Number(declared) !== body.length) return false;
const originalUrl = req.originalUrl ?? req.url;
const [path, query = ''] = originalUrl.split('?', 2);
const prefix = body.subarray(0, Math.min(body.length, PREFIX_BYTES));
const bodyHash = createHash('sha256').update(prefix).digest('hex');
const canonical = [
req.method.toUpperCase(),
path,
query,
bodyHash,
String(body.length),
timestamp,
nonce,
].join('\n');
const expected = createHmac(
'sha256',
process.env.NTHPOOL_ORIGIN_SIGNING_SECRET,
)
.update(canonical)
.digest('hex');
return (
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}
// Express: capture rawBody before JSON parsing, then reject false with 403.Limits and gotchas
- Bodies larger than 1 MB still get a valid signature: only the prefix is
hashed; send a correct
Content-Lengthso the pool can stream without buffering the whole payload. - Without
Content-Length, the pool buffers up to 10 MB. Larger chunked bodies return413 payload_too_largeto the caller. - Inbound
X-Nthpool-*headers are stripped before forwarding, so only the pool can set the outbound signature headers. - Retries are re-signed (new timestamp and nonce each attempt).
- Optional: track nonces at the origin for replay defense; the pool already uses a fresh UUID per attempt.
- When rotating, accept both secrets until you Activate the new one in the dashboard.
See also
- Access (inbound HMAC vs origin verification)
- Pools
- Admission errors