Determine a request came from the proxy

Goal

Reject traffic that hits your origin without going through swarmvia. When Origin verification is enabled, swarmvia signs every forwarded request; your origin checks that signature before doing work.

Reference for headers and limits: Access → Origin verification.

Enable signing

  1. Open Sources → 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 source page also has language-specific verify snippets under the examples panel.

What to verify

On each inbound request at the origin:

  1. Require X-Swarmvia-Timestamp, X-Swarmvia-Nonce, and X-Swarmvia-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 ?).

Node example

import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

const MAX_SKEW_SECONDS = 300;
const PREFIX_BYTES = 1024 * 1024; // 1 MiB

export function cameThroughSwarmvia(req, rawBody) {
    const timestamp = req.headers['x-swarmvia-timestamp'];
    const nonce = req.headers['x-swarmvia-nonce'];
    const signature = req.headers['x-swarmvia-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 swarmvia can stream without buffering the whole payload.
  • Without Content-Length, swarmvia buffers up to 10 MB. Larger chunked bodies return 413 payload_too_large to the caller.
  • Inbound X-Swarmvia-* headers are stripped before forwarding, so only swarmvia 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; swarmvia already uses a fresh UUID per attempt.
  • When rotating, accept both secrets until you Activate the new one in the dashboard.

See also