Skip to content

Secure Mutations

Your browser-facing API needs a mutation endpoint that combines:

  • CSRF protection — the browser auto-attaches session cookies, so you need a signed token to prove intent
  • Bearer token auth — mobile and service clients bypass CSRF but still authenticate
  • Idempotency-Key enforcement — replay protection prevents duplicate charges, double-creates, or repeated side effects

Each middleware is documented individually, but wiring them together requires understanding the pipeline stage ordering, cookie prerequisites, RFC 8941 header quoting, and complete()/discard() lifecycle. This recipe shows the full integration — from CSRF token issuance on GET, through verification + idempotency on POST, to client-side header wiring.

Configure mixed auth with CSRF defaults for browser routes and Bearer-only overrides for API routes. Idempotency applies to mutation routes.

import {
compose,
cookie,
csrf,
authorization,
body,
idempotency,
} from '@centralping/ergo';
const csrfMiddleware = csrf({secret: process.env.CSRF_SECRET});
const bearerStrategy = {
type: 'Bearer',
authorizer: async (attributes, token) => {
const user = await verifySession(token);
return user
? {authorized: true, info: {user}}
: {authorized: false};
},
};
// GET — issues CSRF token cookies for the browser
const issuePipeline = compose(
cookie(),
{fn: csrfMiddleware.issue, setPath: 'csrf'},
authorization({strategies: [bearerStrategy]}),
);
// POST — verifies CSRF + enforces idempotency
const mutationPipeline = compose(
cookie(),
{fn: csrfMiddleware.verify, setPath: 'csrf'},
authorization({strategies: [bearerStrategy]}),
body(),
idempotency({required: true}),
);

The browser must obtain CSRF tokens before submitting mutations:

  1. GET request — the CSRF middleware’s issue function sets two cookies: a token cookie (CSRF-TOKEN, readable by client JS) and a UUID cookie (CSRF-UUID, httpOnly)
  2. POST request — the client reads the token cookie and sends it back as an X-CSRF-TOKEN header. The middleware’s verify function compares the header against the cookies using crypto.timingSafeEqual()

In ergo-router, this dispatch is automatic — safe methods (GET/HEAD/OPTIONS) run issue, unsafe methods (POST/PUT/PATCH/DELETE) run verify.

The complete()/discard() lifecycle stores successful responses for replay and cleans up on failure. complete() returns true when the response is stored, or false if the entry was evicted before completion (the response is not stored and will not be replayed).

import {
compose,
cookie,
csrf,
authorization,
body,
idempotency,
} from '@centralping/ergo';
const csrfMiddleware = csrf({secret: process.env.CSRF_SECRET});
const pipeline = compose(
cookie(),
{fn: csrfMiddleware.verify, setPath: 'csrf'},
authorization({strategies: [bearerStrategy]}),
body(),
idempotency({required: true}),
async (req, res, acc) => {
try {
const result = await processTransfer(acc.body.parsed);
const response = {statusCode: 201, body: result};
if (!acc.idempotency.complete(response)) {
// Evicted — response will not be replayed on subsequent requests
}
return {response};
} catch (err) {
acc.idempotency.discard();
throw err;
}
},
);

A framework-agnostic helper that reads the CSRF token from cookies, constructs all required headers, and applies RFC 8941 quoting to the Idempotency-Key:

function getCookie(name) {
const match = document.cookie.match(
new RegExp(`(?:^|; )${name}=([^;]*)`)
);
return match ? decodeURIComponent(match[1]) : null;
}
async function secureMutation(url, body, idempotencyKey, sessionToken) {
const csrfToken = getCookie('CSRF-TOKEN');
const response = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
// RFC 8941 sf-string: value MUST be double-quoted on the wire
'Idempotency-Key': `"${idempotencyKey}"`,
'Authorization': `Bearer ${sessionToken}`,
},
body: JSON.stringify(body),
});
return response.json();
}

A complete test showing: (1) GET to obtain CSRF cookies, (2) POST with all required headers, (3) replay POST verifying idempotent response.

import {describe, it} from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {fetch} from 'undici';
describe('secure mutation', () => {
let server;
let baseUrl;
let tokenValue;
let cookieHeader;
// Setup: start server with issuePipeline on GET, mutationPipeline on POST
// (elided for brevity — see Testing Patterns recipe for server lifecycle)
it('issues CSRF tokens on GET', async () => {
const res = await fetch(`${baseUrl}/account/transfer`, {
headers: {Authorization: 'Bearer valid-token'},
});
assert.equal(res.status, 200);
const cookies = res.headers.getSetCookie();
const csrfToken = cookies.find((c) => c.startsWith('CSRF-TOKEN='));
const csrfUuid = cookies.find((c) => c.startsWith('CSRF-UUID='));
assert.ok(csrfToken, 'CSRF-TOKEN cookie is set');
assert.ok(csrfUuid, 'CSRF-UUID cookie is set');
});
it('accepts POST with CSRF + idempotency headers', async () => {
// Step 1: GET to obtain tokens
const getRes = await fetch(`${baseUrl}/account/transfer`, {
headers: {Authorization: 'Bearer valid-token'},
});
const cookies = getRes.headers.getSetCookie();
tokenValue = cookies
.find((c) => c.startsWith('CSRF-TOKEN='))
.split('=')[1]
.split(';')[0];
cookieHeader = cookies
.map((c) => c.split(';')[0])
.join('; ');
// Step 2: POST with all headers
const postRes = await fetch(`${baseUrl}/account/transfer`, {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': tokenValue,
Cookie: cookieHeader,
'Idempotency-Key': '"txn-abc-123"',
},
body: JSON.stringify({amount: 1000, to: 'acct-456'}),
});
assert.equal(postRes.status, 201);
});
it('replays idempotent response on duplicate key', async () => {
// (after a successful POST with key "txn-abc-123")
const replayRes = await fetch(`${baseUrl}/account/transfer`, {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': tokenValue,
Cookie: cookieHeader,
'Idempotency-Key': '"txn-abc-123"',
},
body: JSON.stringify({amount: 1000, to: 'acct-456'}),
});
assert.equal(replayRes.status, 201);
const body = await replayRes.json();
// Same response as the original — replayed from store
assert.ok(body);
});
});

The middleware runs in ergo-router’s four-stage Fast Fail pipeline:

Stage Middleware Purpose
1 — Discovery cookie, url, accepts Parse request metadata
2 — Authorization csrf (verify), authorization Verify identity and intent
3 — Validation body, validate, idempotency Parse and validate input
4 — Execution execute handler Business logic

CSRF is in Stage 2 because it verifies request integrity before any authorization logic. Idempotency is in Stage 3 because it needs the parsed body for fingerprinting — it must come after body().

In ergo-router, the pipeline builder automatically dispatches the correct CSRF function based on the HTTP method:

  • Safe methods (GET, HEAD, OPTIONS) → issue — sets token cookies
  • Unsafe methods (POST, PUT, PATCH, DELETE) → verify — checks the X-CSRF-TOKEN header

This means a single csrf: {secret} config handles both sides of the token flow without manual dispatch logic.

The idempotency middleware generates a request fingerprint that includes the parsed body. This is why it runs in Stage 3 (after body() in the pipeline):

  • Same key + same body = replay (returns stored response)
  • Same key + different body = 409 Conflict (fingerprint mismatch)
  • Same key while still processing = 409 Conflict (concurrent request)

The complete(response) call is not automatic — you must call it in your execute handler after the operation succeeds. This design allows the handler to decide whether a result should be stored:

  • Successcomplete(response) stores the response and returns true, or returns false if the entry was evicted before completion
  • Failurediscard() removes the key, allowing a retry with the same idempotency key
  • Neither called → entry stays in processing state (concurrent requests get 409 until TTL expiry)

This explicit lifecycle prevents partial or error responses from being replayed to future requests.