Last reviewed: November 1, 2026 (Asia/Shanghai).

Direct answer

For CometAPI chat CSRF protection, validate a token bound to the authenticated application session before dispatching generation. For a same-origin browser route, also require an exact match against the configured public origin. Reject conflicting Fetch Metadata when present; when it is absent, retain both the origin check and token validation.

Keeping the CometAPI key on the server protects that credential, but the application can still expose a cookie-authenticated spending path. A forged request does not need to read the generated answer to cause unwanted work. The OWASP CSRF Prevention Cheat Sheet recommends server-validated request tokens for stateful applications and explains origin verification and Fetch Metadata as additional controls.

The gate below runs before body parsing in Fastify. Its acceptance rule is deliberately narrow: a valid session, the exact configured Origin, a matching session token, and either Sec-Fetch-Site: same-origin or no Fetch Metadata header. Missing Origin, literal null, sibling origins, and malformed tokens are rejected.

Who this is for

This tutorial is for Node.js developers whose browser chat interface uses an existing application session cookie. Start with the site’s Fastify input-validation route , which already separates browser input from the server-owned CometAPI adapter. This article adds the browser request gate.

Your authentication layer must already validate session credentials, expiry, revocation, and account access. If it provides maintained CSRF middleware, prefer that implementation and use the verification matrix here to check its integration. The small module below makes the ordering and session contract explicit when an equivalent defense is absent.

The example targets one HTTPS origin and one POST route. Cross-origin frontends, webhooks, and service credentials need their own explicit contracts. Do not relax this cookie route to accommodate them.

Key takeaways

  • Generate one unpredictable token for each authenticated session and persist it with that session.
  • Deliver the token through authenticated, non-cacheable application data and submit it in X-CSRF-Token.
  • Compare Origin with trusted deployment configuration, including scheme, host, and port.
  • Require the token even when Fetch Metadata says same-origin.
  • Let an absent Fetch Metadata header fall back to the same origin-and-token checks.
  • Assert zero generation calls on rejection; HTTP 403 alone does not establish that boundary.

Bind the token to the session

OWASP’s synchronizer-token pattern stores the expected value in the user’s server-side session. Generate it when creating a new authenticated session, persist it before returning application bootstrap data, and reuse it for that session’s subsequent requests. Creating a new value on every chat request would race with concurrent tabs and requests.

Use the helper below to generate 32 random bytes encoded as 64 lowercase hexadecimal characters. These sizes and this encoding are local implementation choices. Node.js’s crypto reference documents cryptographically strong output from randomBytes() and the equal-byte-length requirement for timingSafeEqual().

Expose the token to the authenticated page through a same-origin HTML or JSON response with Cache-Control: no-store. Use the template framework’s safe data encoding if embedding it in HTML. Keep it out of URLs, logs, analytics, and shared caches. This synchronizer design stores the expected token in the session, rather than introducing a second token cookie.

Generate a fresh token for a new login session. Destroy the old session on logout and follow the authentication framework’s session-regeneration rules when privilege or account context changes. All application workers must resolve the same persisted session state. A constant application-wide token or a submitted user identifier does not provide that binding.

Build the request gate

Save this as chat-csrf.mjs:

import { randomBytes, timingSafeEqual } from 'node:crypto';

function isToken(value) {
  return typeof value === 'string' && value.length === 64 &&
    /^[0-9a-f]+$/.test(value);
}

export function newSessionCsrfToken() {
  return randomBytes(32).toString('hex');
}

export function createChatCsrfGate({
  publicOrigin,
  loadAuthenticatedSession
}) {
  const origin = new URL(publicOrigin);
  if (origin.protocol !== 'https:' || origin.origin !== publicOrigin ||
      typeof loadAuthenticatedSession !== 'function') {
    throw new Error('invalid_csrf_configuration');
  }

  return async function chatCsrfGate(request, reply) {
    reply.header('cache-control', 'no-store');
    reply.header('vary', 'Origin, Sec-Fetch-Site, Cookie');

    const session = await loadAuthenticatedSession(request);
    if (!session) {
      return reply.code(401).send({ code: 'authentication_required' });
    }

    const sourceOrigin = request.headers.origin;
    const site = request.headers['sec-fetch-site'];
    if (sourceOrigin !== publicOrigin ||
        (site !== undefined && site !== 'same-origin')) {
      return reply.code(403).send({ code: 'request_not_permitted' });
    }

    const submitted = request.headers['x-csrf-token'];
    const expected = session.csrfToken;
    if (!isToken(submitted) || !isToken(expected) ||
        !timingSafeEqual(
          Buffer.from(submitted, 'ascii'),
          Buffer.from(expected, 'ascii')
        )) {
      return reply.code(403).send({ code: 'request_not_permitted' });
    }
  };
}

loadAuthenticatedSession(request) is the adapter to your existing authentication system. It must return the current validated session containing csrfToken, or null for an unauthenticated request. It must not create an authenticated session merely because a cookie exists. If the session store throws, let the request fail through the application’s fixed error handler before generation; never catch that failure and continue.

The token shape check precedes the byte comparison. Both accepted strings produce 64-byte ASCII buffers, so different lengths cannot make timingSafeEqual() throw. Node.js also warns that this primitive does not make surrounding code timing-safe automatically; do not describe the whole request path as constant-time.

Set publicOrigin to the application’s canonical HTTPS origin from trusted deployment configuration. The factory requires that origin’s canonical spelling, without a trailing slash, path, query, or credentials. Avoid deriving the allowlist from a caller-supplied Host or forwarded header. OWASP specifically describes configured target origins as a way to avoid ambiguity behind proxies.

Attach it before the existing JSON gate

In the linked Fastify tutorial’s buildApp, add publicOrigin and loadAuthenticatedSession to the destructured factory options. Import createChatCsrfGate, then define this array inside the factory:

const browserRequestHooks = [
  createChatCsrfGate({ publicOrigin, loadAuthenticatedSession }),
  async (request, reply) => {
    const type = (request.headers['content-type'] ?? '')
      .split(';')[0].trim().toLowerCase();
    if (type !== 'application/json') {
      return reply.code(415).send({ code: 'json_required' });
    }
  }
];

Set the existing /api/chat route option to onRequest: browserRequestHooks. Retain its body-byte limit, strict schema, prompt checks, and generation handler. Attach the hooks to that route rather than globally blocking login, bootstrap, or ordinary page navigation.

Fastify’s Hooks reference places onRequest before body parsing and documents that an early reply stops the remaining hook chain and handler. The CSRF gate therefore reads only session state and headers. Its returned error response prevents the JSON gate, body parser, and generation handler from proceeding.

The CometAPI chat reference documents POST /v1/chat/completions, bearer authentication, and the model/message request contract. Those belong to the server adapter after all application gates pass. Do not forward the browser’s session cookie, CSRF token, or origin headers upstream. Keep the model, endpoint, and CometAPI credential under server control.

Send the token from the browser

Update the application’s existing browser request function to obtain the persisted token from authenticated bootstrap state. Send a POST to the fixed /api/chat path, with a JSON body containing the prompt. Set the media type to application/json and submit the session’s CSRF value in the X-CSRF-Token header.

Keep the request within the application origin: select Fetch’s same-origin mode, retain its default same-origin cookie behavior, and reject redirects. Do not accept a destination URL from prompt text, query parameters, or generated output. Check the HTTP status before consuming the bounded JSON response; route a denial into the interface’s existing fixed error state.

Do not set Origin or Sec-Fetch-Site in browser JavaScript. The browser supplies those headers. MDN’s Sec-Fetch-Site reference identifies it as a forbidden request header and distinguishes same-origin, same-site, cross-site, and none.

In particular, a sibling hostname can be same-site while having a different origin. This route trusts only its exact configured origin. Its policy also rejects none and unknown present values. That is a deliberately strict application policy; it is not a rule for every resource on a website.

Missing Fetch Metadata does not grant a bypass: the exact Origin and valid token remain mandatory. Missing Origin is rejected even with a token. Test that choice against supported browsers and the complete ingress path. If a legitimate client cannot meet it, define a separate reviewed client contract rather than silently accepting unknown origins.

The browser receives the bounded JSON response from the existing application route. Render the returned reply with a safe text or Markdown boundary, as described in the site’s browser rendering tutorial .

Contract details to verify

Run the cases below through the actual route with an injected generation adapter that increments a counter. Keep an additional preParsing counter to check rejection before body parsing. Every denied case must leave both counters unchanged.

Request or lifecycle changeExpected result
Valid session, exact origin, matching token, same-origin metadataNormal application validation runs; a valid prompt dispatches once.
Same valid request with Fetch Metadata absentAccepted through the origin-and-token fallback.
Foreign origin, sibling origin, suffix lookalike, or different port403 before parsing or generation.
Missing or literal null origin403, including when the token is valid.
cross-site, same-site, none, or unknown present metadata403 under this route’s strict policy.
Missing, malformed, wrong-length, or another session’s token403 before parsing or generation.
Unknown or revoked session401 with no generation.
Session token replaced, then old token submitted403 with no generation.
Session store unavailableFixed application failure; no generation and no raw diagnostic.

Use Fastify’s HTTP injection against the application factory, replacing only the session loader and generation dependency with controlled test implementations. Create two distinct synthetic sessions using newSessionCsrfToken(). First submit the matching value for session A and assert one generation call. Then submit session B’s value while authenticated as A and assert rejection without another call. Repeat with an absent token, a rotated token, and a revoked session.

For the origin cases, supply a known valid session and matching token so the test isolates the origin policy. An unauthenticated request returning 401 would not prove that the origin gate rejects an authenticated hostile request. For the store-failure case, make the loader throw and verify the fixed application error without parsing or generation.

The preparation harness used a synthetic session map and exercised the matrix, parser and generation counters, token replacement, session revocation, store failure, and invalid origin configuration. Both test groups passed on Node.js 24.22.1 with Fastify 5.12.7. These local checks made no authenticated CometAPI request and did not establish browser or deployed-proxy behavior. Synthetic session loaders belong only in tests; retain the real authentication adapter in the application.

Before release, repeat the allow and deny cases in supported browsers through the staging proxy. Inspect cookie attributes, bootstrap cache behavior, and whether intermediaries preserve the relevant headers. Use a synthetic generation adapter for hostile fixtures so a regression cannot spend through a live credential.

Failure modes

403 appears after generation. A hook or middleware dispatched upstream work before the gate. Move dispatch behind the route checks and retain the zero-call assertion against the complete application.

A token works across unrelated sessions. The expected value is global, selected from user input, or shared by a cached bootstrap response. Persist a distinct value in each authenticated session and prevent shared caching of token-bearing data.

Valid requests fail after a proxy change. Confirm the configured external origin and header preservation. Do not substitute the internal proxy address or trust arbitrary forwarded-host values to make the comparison pass.

Every second tab fails. A request path regenerates the token without coordinating session state. Use the persisted session value, and invalidate the browser’s bootstrap state when that session actually changes.

A credentialed cross-origin flow stops working. This route intentionally accepts one origin. Document the separate frontend’s authentication, exact origin allowlist, token delivery, and CORS behavior before adding that capability.

CSRF checks pass but abuse continues. Same-origin script injection can bypass CSRF defenses, and an authenticated user can submit unwanted requests directly. Keep output sanitization, authorization, per-user limits, and spending controls. A reusable session token also provides no idempotency guarantee.

Sources checked

These five maintained primary references were fetched successfully on November 1, 2026. They support an evergreen implementation task, rather than a new release or incident. The acceptance rule, token format, and local checks are application engineering choices.

FAQ

Does keeping the CometAPI key on the server prevent CSRF?

It protects the upstream credential. A browser-facing route that accepts session cookies still needs a request-integrity policy before using that credential.

Is checking for JSON or enabling CORS enough?

Keep the explicit origin and token contract. OWASP discusses custom-header defenses and the risks of permissive credentialed CORS. A JSON parser setting alone does not verify a session-bound token.

This synchronizer design uses a separate random value stored inside the server-side session. It does not reveal the session credential to JavaScript. Submit the CSRF value in the custom header and retain the normal protections on the authentication cookie.

Can this gate protect a streaming route?

The gate can run before a streaming handler starts. Verify its integration separately: rejection must happen before upstream dispatch and before any streaming response headers or chunks are sent.

Reader next step

Persist a token in one authenticated development session, add the route hooks, and connect the browser header. Run the rejection matrix with synthetic generation, then verify the same-origin success and hostile-origin failure through staging ingress. Record fixed outcomes, status, and whether dispatch occurred; omit tokens, cookies, prompts, replies, and raw errors from routine diagnostics.