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

  1. Open Pools → Access → Origin verification.
  2. Enable it and copy the osk_… secret once.
  3. Store it as NTHPOOL_ORIGIN_SIGNING_SECRET (or equivalent) on the origin.
  4. 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:

  1. Require X-Nthpool-Timestamp, X-Nthpool-Nonce, and X-Nthpool-Signature.
  2. Reject if the timestamp is outside ±300 seconds.
  3. Let n be the raw body length. If Content-Length is present, reject when it does not equal n.
  4. Build the outbound canonical string (fields joined with \n):
METHOD
PATH
QUERY
SHA256(BODY_PREFIX)
CONTENT_LENGTH
TIMESTAMP
NONCE

BODY_PREFIX is body[0 .. min(n, 1 MiB)). CONTENT_LENGTH is the decimal string of n (0 for empty / GET / HEAD).

  1. 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-Length so the pool can stream without buffering the whole payload.
  • Without Content-Length, the pool buffers up to 10 MB. Larger chunked bodies return 413 payload_too_large to 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