6.1 The server side: a collector endpoint for credentialed beacons
What forced credentials:include demands from the server, the preflight trio, the 204 habit, and a framework-free Node collector.
Intermediate
Beacons force
credentials mode: "include" — every request carries cookies, even in no-cors mode, even cross-origin. That single fixed field turns the server side from an afterthought into the part most likely to silently eat your telemetry. This page assembles the complete endpoint: what the browser sends, what CORS requires in return, and a framework-free Node implementation you can lift into any stack (vanilla applies on the server too — the
node:http module needs nothing else).
What the Beacon specification tells the server
The spec's privacy section states the two-sided contract in exactly two bullets — worth reading in the original, because every server decision in this page follows from them:
"If the request does not contain a payload, or the request Content-Type is a CORS-safelisted request-header, then the request mode is no-cors—similar to an image beacon or form-post respectively. Otherwise, a CORS preflight is made and the server needs to first allow such requests by returning the appropriate set of CORS headers: Access-Control-Allow-Credentials, Access-Control-Allow-Origin, Access-Control-Allow-Headers."— Beacon, Privacy and Security
For the no-cors family (string, form, and multipart payloads), the server side needs
nothing — the request lands like a form post, and its opaque response is discarded unread. For the cors-mode family (any non-safelisted Content-Type, such as a JSON-typed Blob), the server must answer a preflight with three headers, and because credentials are forced on, the credentials-specific rules bind:
Access-Control-Allow-Origin must echo a
specific origin — the wildcard
* is categorically invalid for credentialed responses in the CORS protocol — and
Access-Control-Allow-Credentials: true must be present.
Access-Control-Allow-Headers covers the requested headers (in practice,
Content-Type).
The 204 habit
A beacon never reads its response, so response bodies are pure waste — bytes the client will discard and the network carried for nothing. The spec's note says the server
is encouraged to omit response bodies, suggesting
204 No Content as the canonical status. Encouraged, not required: a 200 with an empty body works identically as far as delivery goes. But 204 is the honest signal — nothing follows — and it is what the recipe below sends.
The collector, framework-free
The full handler covers the four cases a beacon endpoint meets: a POST from a known origin (answer 204 with credentialed CORS headers), an OPTIONS preflight (answer 204 with the three allow-headers plus
Access-Control-Allow-Methods and a cache lifetime), an unknown origin (no CORS headers — the browser drops the response, which is the correct containment), and anything that is not POST or OPTIONS (405). It ships as one file with a built-in smoke check that verifies its own CORS contract end to end:
// The collector endpoint: CORS for credentialed beacons, with zero
// frameworks — plain Node's http module. Vanilla applies server-side
// too in this series.
//
// The spec facts this handler encodes:
// - Beacon requests always carry credentials mode "include"
// (even in no-cors mode), so cookies ride along. A credentialed
// CORS response may NOT use Access-Control-Allow-Origin: * —
// the origin must be echoed back, and
// Access-Control-Allow-Credentials: true must be present.
// - Non-safelisted Content-Types (e.g. application/json via a
// typed Blob) flip the beacon into cors mode: the browser sends
// an OPTIONS preflight, and the server must answer
// Access-Control-Allow-Methods and Access-Control-Allow-Headers.
// - The Beacon spec's note: "The server is encouraged to omit
// returning a response body for such requests (e.g. respond
// with 204 No Content)."
//
// Run: node collector.mjs (then POST/inspection with curl or any
// HTTP client; a runnable smoke check follows the handler)
import { createServer } from 'node:http';
const ALLOWED_ORIGINS = new Set([
'https://my-site.example',
'https://app.my-site.example',
]);
function corsHeaders(req) {
const origin = req.headers.origin;
if (!ALLOWED_ORIGINS.has(origin)) return null; // unknown origin: no CORS headers
return {
// Echo the specific origin — '*' is forbidden with credentials:
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
};
}
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/api/collect') {
res.writeHead(404).end();
return;
}
const cors = corsHeaders(req);
if (req.method === 'OPTIONS') {
// Preflight (beacons with a non-safelisted Content-Type):
if (!cors) { res.writeHead(204).end(); return; }
res.writeHead(204, {
...cors,
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}).end();
return;
}
if (req.method === 'POST') {
let bytes = 0;
req.on('data', (chunk) => { bytes += chunk.length; });
req.on('end', () => {
// 204 No Content: the spec-encouraged body-less response:
res.writeHead(204, cors || {}).end();
console.log(`collected ${bytes} bytes from ${req.headers.origin}`);
});
return;
}
res.writeHead(405).end();
});
// -- Smoke check: verify the handler's CORS contract ----------------
if (import.meta.url === `file://${process.argv[1]}`) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const preflight = await fetch(base + '/api/collect', {
method: 'OPTIONS',
headers: {
origin: 'https://my-site.example',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
});
console.log('preflight status :', preflight.status);
console.log('preflight allow-origin :', preflight.headers.get('access-control-allow-origin'));
console.log('preflight allow-cred :', preflight.headers.get('access-control-allow-credentials'));
console.log('preflight allow-headers:', preflight.headers.get('access-control-allow-headers'));
const post = await fetch(base + '/api/collect', {
method: 'POST',
headers: { origin: 'https://my-site.example', 'content-type': 'text/plain;charset=UTF-8' },
body: '[{"event":"pageview"}]',
});
console.log('POST status :', post.status, '(204 = no body, per the spec note)');
const stranger = await fetch(base + '/api/collect', {
method: 'OPTIONS',
headers: { origin: 'https://evil.example', 'access-control-request-method': 'POST' },
});
console.log('unknown origin gets no ACAO:',
stranger.headers.get('access-control-allow-origin') === null);
server.close();
}Note the two details tutorials skip. The
Vary: Origin header on every CORS response keeps intermediaries from caching one origin's allowance and replaying it to another. And the preflight's
Access-Control-Max-Age matters more for beacons than for ordinary APIs: a cached preflight is the difference between one OPTIONS round trip per page load and one per beacon — which, at telemetry volume, is real money.
The one legal use of the wildcard
If a collector genuinely serves anonymous third-party traffic — no cookies, no client state — the wildcard becomes reachable through the
client instead of the server:
fetch(url, { keepalive: true, credentials: 'omit' }) sends a cookie-less request whose response may then legitimately use
Access-Control-Allow-Origin: *. That is the standard shape for third-party collectors that do not need identity, and it is one more entry in the trade-off table the next page assembles. Safari's Intelligent Tracking Prevention deserves a mention here as the boundary condition: it is browser policy, version-specific, and aimed squarely at cross-site cookie flows — the spec has nothing to say about it, and anything this series could print about its details would be stale within a release cycle. Check current WebKit documentation when cross-site cookies matter to you.
Quick check
Your beacon carries cookies (it always does). Which Access-Control-Allow-Origin may the collector return?
// collector response header:
'Access-Control-Allow-Origin':
Key takeaways
-
Beacons force
credentials: "include"— cookies always ride, so credentialed CORS rules always bind. -
no-cors payloads need zero server setup; cors-mode payloads need the preflight trio
ACAO+ACAC+ACAH. -
Echo a
specific origin — the wildcard is invalid for credentialed responses; add
Vary: Origin. -
Respond
204 No Content— the spec-encouraged body-less answer; cache preflights withMax-Age. -
For anonymous third-party collection:
fetch(keepalive, credentials:'omit')+ACAO: *is the legal pairing.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.