All writing

Verify the signature before you burn the nonce

Order the checks in a replay-protected auth scheme so that unauthenticated callers can't consume nonces, poison retries, or grow your replay table.

If your API uses signed requests with nonces for replay protection, the order in which you check the nonce and verify the signature is a real design decision — and the intuitive order is wrong. My gateway, Cookey, originally consumed the nonce before verifying the Ed25519 signature. That version had three bugs, none of which show up in a demo and all of which show up in production:

  1. Anyone can burn nonces. An unauthenticated attacker who observes (or guesses) a request’s nonce can submit garbage with that nonce first. The legitimate, correctly-signed request then fails with “replay detected” — a denial of service that requires no credentials at all.
  2. Failed requests poison their own retries. A client whose signature fails for an innocent reason — clock skew fixed between attempts, a proxy that mutated the body — has already consumed its nonce. If the client retries the same signed request (which is exactly what naive retry logic does), it gets rejected as a replay of a request that never succeeded.
  3. Unauthenticated traffic writes to your database. Nonce consumption is a durable write (in Cookey’s case, an INSERT into a PopNonce table). Checking the nonce first means any drive-by POST grows a table that only a cron job cleans up. That’s an unauthenticated disk-fill primitive.

The boundary

The rule that fixes all three: a nonce is consumed only by a correctly-signed request. Everything cheap and stateless — header presence, timestamp window, app status — runs first. The signature check runs next. Only a request that has proven possession of the private key gets to write to the replay table.

This does not attempt to make nonce consumption free for attackers who hold the key. If your signing key is stolen, replay protection is not your problem anymore. The boundary is precisely: no state change on behalf of a request that hasn’t authenticated.

Implementation

Cookey’s PoP (proof-of-possession) scheme signs a canonical string over method, path, app ID, timestamp, nonce, and body hash. The verification pipeline lives in apps/proxy/src/server/auth/pop.ts — this is the code that runs on every PoP-authenticated request, ordered deliberately:

// 4. Lookup app and verify status
// (Nonce consumption happens AFTER signature verification — an
// unauthenticated caller must not be able to burn nonces or grow
// the PopNonce table, and a failed signature must not consume the
// nonce the client will legitimately retry with.)
const app = await prisma.app.findUnique({ /* … */ });

// 5. Build canonical request string (v1: path includes query)
const canonicalString = buildCanonicalRequestV1({
  method: request.method,
  pathWithQuery: getPathWithQuery(url),
  appId: headers.appId,
  ts: headers.timestamp,
  nonce: headers.nonce,
  bodyHash: hashBody(body),
});

// 6. Verify signature against any active credential
for (const credential of app.credentials) {
  const valid = await verifySignatureWithCanonical(
    credential.publicKey,
    headers.signature,
    canonicalString,
  );

  if (valid) {
    // 7. Consume the nonce only for a correctly-signed request
    const nonceValid = await checkAndSetNonce(headers.nonce);
    if (!nonceValid) {
      return {
        success: false,
        error: "Replay detected: nonce already used",
        errorCode: ErrorCode.ERR_INVALID_NONCE,
      };
    }
    return { success: true, appId: headers.appId };
  }
}

return { success: false, error: "Invalid signature", /* … */ };

The nonce store itself (apps/proxy/src/server/limits/nonce.ts) uses the database’s unique constraint as the atomic check-and-set — an insert that succeeds is a fresh nonce, a unique-violation is a replay:

// TTL = PoP timestamp window (±90s) × 2. A nonce only needs to survive as
// long as its timestamp would still validate; expired rows are swept by cron.
const NONCE_TTL_SECONDS = 180;

export async function checkAndSetNonce(nonce: string): Promise<boolean> {
  try {
    await prisma.popNonce.create({
      data: {
        nonce,
        expiresAt: new Date(Date.now() + NONCE_TTL_SECONDS * 1000),
      },
    });
    return true;
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError &&
      error.code === "P2002"
    ) {
      return false; // already seen — replay
    }
    throw error;
  }
}

If you’re adapting this: the ordering logic belongs in your auth middleware, directly where signature verification already happens — not in a separate “rate limiting” layer that runs earlier in the request lifecycle, which is where nonce checks often end up by accident because they feel like throttling. The nonce store is any table with a unique index on the nonce column; you don’t need Redis for it (the insert-or-conflict pattern is atomic in any SQL database).

Note the TTL: it’s derived from the signature’s timestamp window, not chosen independently. A signed request outside the ±90-second timestamp window is rejected before the nonce is ever checked, so a nonce only needs to survive about twice that window. Deriving one constant from the other keeps the replay table small and makes the relationship auditable.

Failure behavior

Two failure modes matter. If the database insert errors (not conflicts — errors), checkAndSetNonce throws and the request fails closed: no auth, no upstream call. That’s the correct degradation for an auth primitive — availability is sacrificed, replay protection is not.

The subtler case is the race between two concurrent submissions of the same signed request. Both pass signature verification; both attempt the insert; exactly one wins the unique constraint and the other gets a replay rejection. The unique index is what makes this safe — a read-then-write nonce check (“SELECT, then INSERT if absent”) admits both requests under concurrency, which is precisely the replay you built the mechanism to prevent.

What changes operationally

Once nonce consumption is gated on signature verification, your replay table’s growth rate is bounded by authenticated traffic, which you already meter and can already revoke. Garbage traffic hitting the endpoint costs one indexed SELECT (the app lookup) and one signature verification — CPU, not durable state. And your on-call story for “client reports spurious replay errors” collapses to a single cause worth investigating (an actual duplicate submission) instead of three, because failed signatures and unauthenticated probes can no longer leave replay-table residue behind.