First-party proxy
By default the Sourcream script loads from https://cdn.sourcream.ai and sends its data there. That is standard mode, and it works everywhere. First-party mode serves the same script and endpoints from your own domain under a path such as /_sc, through a small proxy you run on your own hosting or CDN.
Why it matters
Safari (ITP) caps cookies written by JavaScript at 7 days. Since Safari 16.4 it also caps cookies set by a server when that server is a CNAME to someone else's infrastructure or its IP address does not match your website's. A plain t.yourdomain.com CNAME to Sourcream therefore still gets 7 days.
A proxy on your own domain avoids both: the collect response that sets the server cookie _sc_id (HttpOnly, 180 days) comes from your own hostname and your own IP range. Sourcream keeps the first touch on our servers against that visitor ID, so a visitor who clicked a Google ad in January and converts in April still reports google / cpc as first touch, even in Safari.
| Standard mode | First-party mode | |
|---|---|---|
| Script and data calls | cdn.sourcream.ai |
yourdomain.com/_sc/... |
| Visitor ID in Safari | Script cookie, capped at 7 days | Server cookie _sc_id, kept 180 days |
| First touch on our servers | Yes, keyed to the script's visitor ID | Yes, keyed to the server cookie |
Blocklists that match sourcream hosts |
Can block it | Do not match your own domain |
Honest limits:
- It reduces blocking. It is not unblockable. Filter lists can match paths such as
/_sc/and extensions can recognise scripts by content. uBlock Origin on Firefox uncloaks CNAME setups, which is why we use a path proxy and not a CNAME. - Sites hosted on Webflow, Squarespace and most other hosted builders cannot proxy a path, so they run in standard mode. Standard mode still stores first touch on our servers, keyed to the script's visitor ID, which Safari caps at 7 days: a Safari visitor who comes back after more than 7 days without a new ad click starts a new visitor record.
- A proxy you run is part of your site. If it is down, the script does not load (your pages are not affected).
- Sourcream tells proxies apart by the zone of a Cloudflare Worker or by the address calls leave from (an IPv6 address by its /56). On Vercel, Netlify and shared hosting that address is shared, and so are its rate limits (shared addresses). Your proxy's path is public too: anyone can send calls through it, and they count in your proxy's limits.
- Anyone can fill your site's hourly caps, whatever proxy you use. Your site's new visitors and lead beacons then get 429 until the next UTC hour, while leads sent to the webhook intake keep arriving (hourly caps).
What any proxy must do
Use the same rules whichever option below you choose. With the default base path /_sc:
| Browser request | Forward to |
|---|---|
GET /_sc/t/<site key>.js |
GET https://cdn.sourcream.ai/t/<site key>.js?b=/_sc |
POST /_sc/c |
POST https://cdn.sourcream.ai/c |
POST /_sc/l |
POST https://cdn.sourcream.ai/l |
OPTIONS on /_sc/c or /_sc/l |
the same path on https://cdn.sourcream.ai |
anything else under /_sc/ |
answer 404, do not forward |
Add or keep these request headers:
X-Sourcream-Proxy: 1(this is how Sourcream recognises first-party mode)X-Forwarded-Host: <your hostname>andX-Forwarded-Proto: httpsOrigin,User-AgentandContent-Typefrom the browserCookiewith only_sc_id=<value>; strip every other cookieX-Sourcream-Client-IP: <the visitor's IP address>onPOST /_sc/candPOST /_sc/l: the address the visitor connected from, as your own server or CDN sees it, never a value copied from the visitor's request- the
Hostheader must becdn.sourcream.ai(do not preserve your own host)
Sourcream limits how often one visitor can send data: 120 collect calls (/c) and 10 lead calls (/l) a minute, with a visitor on IPv6 counted by the /56 block their address is in. Through a proxy, X-Sourcream-Client-IP is the only way it can tell your visitors apart:
- With it, each visitor has a limit of their own (visitors in the same IPv6 /56 share one), counted together with your proxy's identity: your zone for the Cloudflare Worker, otherwise the address your proxy's calls leave from (over IPv6, its /56). A sender elsewhere who names one of your visitors' addresses counts under their own identity, not yours. Anyone who sends from the same address (or /56) as your proxy has the same identity, though, and can use up the limit of a visitor whose address they name (shared addresses).
- Without it there is no per visitor limit, and all your visitors share your site's one bucket for that proxy: 6,000 calls a minute on each endpoint. Real visitors alone rarely come near that, but on a shared address other senders count in it too (shared addresses). Private addresses (such as a load balancer's on your own network) count as no address, with the same result.
- With the wrong address, for example the address of a CDN or load balancer in front of your server instead of the visitor's, every visitor arriving through that address shares one limit of 120 collect calls and 10 lead calls a minute, and a busy site loses data. Each option below says where the visitor's address comes from.
Set or remove the header on every collect and lead call; never pass on a value the visitor sent. A false address that your proxy passes on lets its sender escape their own limit, or use up the limit of the address they name, for calls from your proxy's identity, and your site's bucket still applies.
Pass the response back as is, including Set-Cookie, Cache-Control and the Access-Control-* headers. Never cache the POST responses. The script may be cached for as long as its Cache-Control says (5 minutes).
The ?b=/_sc on the script request tells Sourcream which path you proxy, and the script then sends its data to that path. If you choose another base path, use it in all three places: the proxy routes, the b value and the script tag.
Then change the script tag in your <head> to the proxied path (the Install tab shows it ready to copy):
<script src="/_sc/t/YOUR_SITE_KEY.js" async></script>
Check it works
- Load a page on your site with the developer tools open, Network tab.
/_sc/t/YOUR_SITE_KEY.jsreturns 200 with JavaScript.- A
POST /_sc/creturns 200 and its response headers includeSet-Cookie: _sc_id=...; HttpOnly; Secure. - Application (Chrome) or Storage (Safari, Firefox) shows the
_sc_idcookie on your domain, marked HttpOnly, expiring in about 180 days. - In Sourcream, the site's Install tab reports that the script has been seen through the first-party proxy.
If you set a Cookie domain in the site's settings (for example acme.com.au), _sc_id is scoped to it so that www.acme.com.au and go.acme.com.au share one visitor. Run the proxy on every hostname that serves pages.
Proxies on shared addresses
Sourcream knows a proxy only by its identity: the zone that owns it for the Cloudflare Worker (only Cloudflare can set that), otherwise the address its calls leave from, with an IPv6 address counted by its /56, so every server in the same /56 has the same identity. Each identity has a bucket across every site whose calls come from it (6,000 calls a minute on each endpoint), and each site has a bucket for it of the same size, which each Sourcream Worker instance also holds to a best effort budget of 1,200 collect and 120 lead writes a minute. Anyone who sends calls with the same identity counts in the same buckets, and so does anyone who sends calls through your proxy's public path, whichever option you use.
- Kept apart from other sites: the Cloudflare Worker (options 1 and 2), counted by your zone, and a proxy on your own server when nobody else sends from its identity: an IPv4 address of its own, with the server reaching Sourcream over IPv4 only, or a whole IPv6 /56 of its own. Sourcream answers over IPv6 too, so a server with IPv6 usually connects over it, and many hosts give each server one IPv6 address or a /64 out of a /56 that other customers' servers share: that server is then not kept apart, even with an IPv4 address of its own. Ask your host, or run
curl -6 https://cdn.sourcream.ai/healthzon the server: if it fails, the server has no IPv6 route and reaches Sourcream over IPv4. Other sites' calls never count in these buckets; calls anyone sends through your proxy's public path still do. - Not kept apart: Vercel (options 3 and 4 on Vercel), Netlify (option 5) and shared web hosting, including WordPress on shared hosting (option 8), send many customers' traffic from the same addresses, and a server that connects over IPv6 shares its /56 with every other server in it. Your site's calls then share their address's buckets with every other site behind that address and with anyone else who sends from it, including someone who forges first-party headers that name your site. If they fill a bucket, your proxied calls get 429 for the rest of that minute. Where the proxy forwards visitor addresses, a sender on the same address can also name one of your visitors' addresses and use up that visitor's limit.
Sourcream's hourly caps per site still bound how many new visitors, journey rows and leads anyone can store for your site in an hour, whatever address they send from. If your site is on Vercel, Netlify or shared hosting, the Cloudflare Worker (option 1) is the better option where your DNS can be on Cloudflare: it is counted by your zone, so other sites never count in its buckets.
Hourly caps and the webhook intake
Your site key is public, so anyone can send tracking calls for your site, straight to Sourcream or through your proxy's public path. That means anyone can fill your site's hourly caps, whatever proxy you use or none. In each UTC hour Sourcream stores at most 20,000 new visitors, 1,000 lead beacons and 50,000 further journey rows for visitors it already has per site (a new visitor brings at most three journey rows of its own). Once the new visitor or lead beacon cap is full, your site's new visitors and lead beacons get 429 until the next UTC hour, and a form submission that only the script reports in that time is lost. Returning visitors still update, and past the journey row cap a visit still updates attribution, without its journey row.
Leads sent server to server have a cap of their own, 10,000 an hour, which tracking calls never count towards. The webhook intake URL and the API (POST /v1/leads, Agency plan) keep working while the tracking caps are full. That makes the intake URL the robust way to capture leads: send each form submission to it as well as running the script, with Webflow's form webhook, a Zapier step, the Gravity Forms Webhooks add-on, Typeform's webhook or your own backend (Zapier, Monday.com and webhooks, Webflow). When the script and the intake report the same submission, Sourcream merges them into one lead.
Option 1: Cloudflare Worker (recommended when your DNS is on Cloudflare)
The Worker in apps/proxy implements exactly the rules above, strips cookies properly and caches the script at the edge. Your site's DNS record must be proxied through Cloudflare (orange cloud).
With Wrangler:
- Get the
apps/proxyfolder (from us or the repository) and runnpm install --save-dev wranglerin it. - In
wrangler.jsonc, setroutesto your hostname and zone, for example{ "pattern": "www.acme.com.au/_sc/*", "zone_name": "acme.com.au" }. Add the apex too if it serves pages, or use"*acme.com.au/_sc/*". - Leave
SOURCREAM_ORIGINashttps://cdn.sourcream.aiandBASE_PATHas/_sc. npx wrangler login, thennpx wrangler deploy.- Change the script tag to
/_sc/t/YOUR_SITE_KEY.jsand run the checks above.
The Worker sends each visitor's address in X-Sourcream-Client-IP, taken from Cloudflare's own CF-Connecting-IP (a value the visitor sends in either header is never used).
Its route is public, like any proxy's path: anyone can send calls through it, and they count in your zone's limits, the ones your visitors use. A sender on an ordinary address gets a per visitor limit of its own there. A sender that calls your route from another Cloudflare Worker arrives from Cloudflare's shared Worker address, which Sourcream cannot use as a visitor address, so it has no per visitor limit through your route: only your zone's limits (6,000 calls a minute on each endpoint) and your site's hourly caps hold it.
Option 2: Cloudflare dashboard (no build tools)
The same Worker, pasted into the Cloudflare dashboard.
- In Cloudflare, open Workers & Pages, choose Create, start from the Hello World Worker, name it
sourcream-proxyand deploy it. - Open the Worker, choose Edit code, replace everything with the code below and deploy.
- Under the Worker's Settings, Variables and Secrets, add two plain text variables:
SOURCREAM_ORIGIN=https://cdn.sourcream.aiandBASE_PATH=/_sc. (Both are the defaults, so this step is optional.) - Under Settings, Domains & Routes, add a Route: choose your zone and enter
www.yourdomain.com/_sc/*(andyourdomain.com/_sc/*if the apex serves pages). - In the same section, disable the
workers.devaddress: the proxy only works on your own domain. - Change the script tag to
/_sc/t/YOUR_SITE_KEY.jsand run the checks above.
// Sourcream first-party proxy for Cloudflare Workers (single file build of apps/proxy).
// Paste into a Worker in the Cloudflare dashboard, set the variables SOURCREAM_ORIGIN
// (https://cdn.sourcream.ai) and BASE_PATH (/_sc), then add the route example.com/_sc/*.
// Questions: [email protected]
// src/proxy.ts
var DEFAULT_ORIGIN = "https://cdn.sourcream.ai";
var DEFAULT_BASE_PATH = "/_sc";
var MAX_BODY_BYTES = 32 * 1024;
var UPSTREAM_TIMEOUT_MS = 1e4;
var SERVER_COOKIE = "_sc_id";
var PASSTHROUGH_HEADER = "X-Sourcream-Passthrough";
var BASE_PATH_RE = /^\/[A-Za-z0-9_\-/]{1,40}$/;
var SCRIPT_KEY_RE = /^[A-Za-z0-9_-]{1,64}$/;
var COOKIE_VALUE_RE = /^[A-Za-z0-9._~-]{1,128}$/;
var CLIENT_IP_RE = /^[0-9A-Fa-f:.]{2,45}$/;
var CLIENT_IP_HEADER = "X-Sourcream-Client-IP";
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
var HOP_BY_HOP = /* @__PURE__ */ new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade"
]);
var NULL_BODY_STATUS = /* @__PURE__ */ new Set([101, 204, 205, 304]);
var ConfigError = class extends Error {
};
function resolveConfig(env) {
const rawOrigin = (env.SOURCREAM_ORIGIN ?? "").trim() || DEFAULT_ORIGIN;
let parsed;
try {
parsed = new URL(rawOrigin);
} catch {
throw new ConfigError(`SOURCREAM_ORIGIN is not a valid URL: ${rawOrigin}`);
}
const local = LOCAL_HOSTS.has(parsed.hostname);
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local)) {
throw new ConfigError("SOURCREAM_ORIGIN must use https (http is accepted for localhost only).");
}
if (parsed.username || parsed.password) {
throw new ConfigError("SOURCREAM_ORIGIN must not contain credentials.");
}
if (parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash) {
throw new ConfigError("SOURCREAM_ORIGIN must be an origin only, for example https://cdn.sourcream.ai");
}
let basePath = (env.BASE_PATH ?? "").trim() || DEFAULT_BASE_PATH;
if (!basePath.startsWith("/")) basePath = `/${basePath}`;
basePath = basePath.replace(/\/+$/, "");
if (!BASE_PATH_RE.test(basePath) || basePath.includes("//")) {
throw new ConfigError('BASE_PATH must look like /_sc: a leading slash, then up to 40 letters, digits, "_", "-" or "/".');
}
return { origin: parsed.origin, basePath };
}
function isUnderBase(pathname, basePath) {
return pathname === basePath || pathname.startsWith(`${basePath}/`);
}
function matchRoute(pathname, basePath) {
if (!pathname.startsWith(`${basePath}/`)) return null;
const rest = pathname.slice(basePath.length);
if (rest === "/c" || rest === "/l") return { kind: "beacon", upstreamPath: rest };
const m = /^\/t\/([^/]+)\.js$/.exec(rest);
if (m && SCRIPT_KEY_RE.test(m[1])) return { kind: "script", key: m[1] };
return null;
}
function readServerCookie(cookieHeader) {
if (!cookieHeader) return null;
for (const part of cookieHeader.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() !== SERVER_COOKIE) continue;
const value = part.slice(eq + 1).trim();
return COOKIE_VALUE_RE.test(value) ? value : null;
}
return null;
}
function isServerSetCookie(setCookie) {
const eq = setCookie.indexOf("=");
return eq > 0 && setCookie.slice(0, eq).trim() === SERVER_COOKIE;
}
function visitorAddress(request) {
const value = (request.headers.get("CF-Connecting-IP") ?? "").trim();
return CLIENT_IP_RE.test(value) ? value : null;
}
function buildUpstreamHeaders(request, url, opts) {
const headers = new Headers();
headers.set("X-Sourcream-Proxy", "1");
headers.set("X-Forwarded-Host", url.hostname);
headers.set("X-Forwarded-Proto", url.protocol.replace(/:$/, ""));
const copy = (name) => {
const value = request.headers.get(name);
if (value !== null && value !== "") headers.set(name, value);
};
copy("Origin");
copy("User-Agent");
if (opts.withContentType) copy("Content-Type");
if (opts.withPreflight) {
copy("Access-Control-Request-Method");
copy("Access-Control-Request-Headers");
}
if (opts.withCookie) {
const id = readServerCookie(request.headers.get("Cookie"));
if (id) headers.set("Cookie", `${SERVER_COOKIE}=${id}`);
}
if (opts.withClientIp) {
const ip = visitorAddress(request);
if (ip) headers.set(CLIENT_IP_HEADER, ip);
}
return headers;
}
function passThrough(upstream, opts = {}) {
const headers = new Headers();
upstream.headers.forEach((value, key) => {
const k = key.toLowerCase();
if (k === "set-cookie" || k === "content-length" || HOP_BY_HOP.has(k)) return;
headers.append(key, value);
});
for (const cookie of upstream.headers.getSetCookie()) {
if (isServerSetCookie(cookie)) headers.append("Set-Cookie", cookie);
}
if (opts.defaultCacheControl && !headers.has("Cache-Control")) headers.set("Cache-Control", opts.defaultCacheControl);
const body = opts.head || NULL_BODY_STATUS.has(upstream.status) ? null : upstream.body;
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
}
function isCacheable(res) {
if (res.status !== 200) return false;
if (res.headers.getSetCookie().length > 0) return false;
if ((res.headers.get("Vary") ?? "").includes("*")) return false;
const cc = (res.headers.get("Cache-Control") ?? "").toLowerCase();
if (!cc || /(?:^|,)\s*(?:no-store|no-cache|private)\b/.test(cc)) return false;
const age = /(?:^|,)\s*(?:s-maxage|max-age)\s*=\s*"?(\d+)"?/.exec(cc);
return age !== null && Number(age[1]) > 0;
}
function etagMatches(ifNoneMatch, etag) {
if (!ifNoneMatch || !etag) return false;
const strip = (t) => t.trim().replace(/^W\//, "");
const target = strip(etag);
return ifNoneMatch.split(",").some((candidate) => {
const c = candidate.trim();
return c === "*" || strip(c) === target;
});
}
async function readLimitedBody(request, limit) {
const declared = request.headers.get("Content-Length");
if (declared !== null && /^\d+$/.test(declared.trim()) && Number(declared) > limit) return null;
if (!request.body) return new ArrayBuffer(0);
const reader = request.body.getReader();
const chunks = [];
let total = 0;
for (; ; ) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > limit) {
await reader.cancel().catch(() => void 0);
return null;
}
chunks.push(value);
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out.buffer;
}
function log(event, data) {
console.error(JSON.stringify({ level: "error", event, ...data }));
}
function textResponse(status, message, contentType = "text/plain; charset=utf-8") {
return new Response(message, {
status,
headers: { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }
});
}
function jsonError(status, code, message) {
return textResponse(status, JSON.stringify({ error: { code, message } }), "application/json; charset=utf-8");
}
function notFound() {
return textResponse(404, "Not found");
}
async function fetchUpstream(request, what) {
try {
return await fetch(request);
} catch (err) {
log("sourcream_proxy_upstream_error", { what, message: err instanceof Error ? err.message : String(err) });
return null;
}
}
function defaultCache() {
try {
return typeof caches !== "undefined" && caches.default ? caches.default : null;
} catch {
return null;
}
}
function finishScript(res, request) {
const head = request.method === "HEAD";
if (res.status === 200 && etagMatches(request.headers.get("If-None-Match"), res.headers.get("ETag"))) {
const headers = new Headers();
for (const name of ["Cache-Control", "ETag", "Vary", "Expires", "Last-Modified"]) {
const v = res.headers.get(name);
if (v !== null) headers.set(name, v);
}
return new Response(null, { status: 304, headers });
}
if (head) return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
return res;
}
async function handleScript(request, url, config, key, ctx) {
const cache = defaultCache();
const cacheKey = new Request(`${url.origin}${config.basePath}/t/${key}.js?sc_origin=${encodeURIComponent(config.origin)}`, { method: "GET" });
if (cache) {
try {
const hit = await cache.match(cacheKey);
if (hit) return finishScript(hit, request);
} catch (err) {
log("sourcream_proxy_cache_error", { op: "match", message: err instanceof Error ? err.message : String(err) });
}
}
const upstream = await fetchUpstream(
new Request(`${config.origin}/t/${key}.js?b=${config.basePath}`, {
method: "GET",
headers: buildUpstreamHeaders(request, url, { withCookie: false, withContentType: false, withPreflight: false }),
redirect: "manual",
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
}),
"script"
);
if (!upstream) return textResponse(502, "/* sourcream: the first-party proxy could not reach Sourcream */", "application/javascript; charset=utf-8");
const res = passThrough(upstream);
if (cache && isCacheable(res)) {
const copy = res.clone();
ctx.waitUntil(
cache.put(cacheKey, copy).catch((err) => {
log("sourcream_proxy_cache_error", { op: "put", message: err instanceof Error ? err.message : String(err) });
})
);
}
return finishScript(res, request);
}
async function handleBeacon(request, url, config, upstreamPath) {
const preflight = request.method === "OPTIONS";
let body;
if (!preflight) {
const read = await readLimitedBody(request, MAX_BODY_BYTES);
if (read === null) return jsonError(413, "payload_too_large", "Request body too large.");
body = read;
}
const upstream = await fetchUpstream(
new Request(`${config.origin}${upstreamPath}`, {
method: request.method,
headers: buildUpstreamHeaders(request, url, { withCookie: true, withContentType: !preflight, withPreflight: preflight, withClientIp: !preflight }),
body,
redirect: "manual",
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
}),
upstreamPath === "/c" ? "collect" : "lead"
);
if (!upstream) return jsonError(502, "upstream_unavailable", "The first-party proxy could not reach Sourcream.");
return passThrough(upstream, { defaultCacheControl: "no-store" });
}
async function handleRequest(request, env, ctx) {
let config;
try {
config = resolveConfig(env);
} catch (err) {
log("sourcream_proxy_misconfigured", { reason: err instanceof Error ? err.message : String(err) });
return textResponse(500, `Sourcream proxy is misconfigured. ${err instanceof Error ? err.message : ""}`.trim());
}
const url = new URL(request.url);
if (!isUnderBase(url.pathname, config.basePath)) {
if (request.headers.get(PASSTHROUGH_HEADER) === "1") {
return textResponse(508, "Loop detected: nothing behind this Worker serves paths outside BASE_PATH.");
}
const headers = new Headers(request.headers);
headers.set(PASSTHROUGH_HEADER, "1");
return fetch(new Request(request, { headers }));
}
const route = matchRoute(url.pathname, config.basePath);
if (!route) return notFound();
const method = request.method.toUpperCase();
if (route.kind === "script") {
if (method !== "GET" && method !== "HEAD") return notFound();
return handleScript(request, url, config, route.key, ctx);
}
if (method !== "POST" && method !== "OPTIONS") return notFound();
return handleBeacon(request, url, config, route.upstreamPath);
}
// src/index.ts
var index_default = {
fetch(request, env, ctx) {
return handleRequest(request, env, ctx);
}
};
export {
index_default as default
};
The code sends X-Sourcream-Client-IP from Cloudflare's CF-Connecting-IP on collect and lead calls. If a copy you pasted earlier does not contain X-Sourcream-Client-IP, replace it with the code above: an older copy still works, but without the per visitor limit.
Option 3: Next.js (Vercel or self hosted)
Add this file at the project root (next to app/ or pages/). On Next.js 16 and later name it proxy.ts and rename the exported function to proxy; on earlier versions name it middleware.ts and keep middleware. If you already have one, merge the logic and add /_sc/:path* to its matcher.
import { NextResponse, type NextRequest } from 'next/server';
const SOURCREAM_ORIGIN = 'https://cdn.sourcream.ai';
const BASE_PATH = '/_sc';
const SCRIPT_RE = /^\/_sc\/t\/([A-Za-z0-9_-]{1,64})\.js$/;
const FORWARD = ['origin', 'user-agent', 'content-type', 'access-control-request-method', 'access-control-request-headers'];
export function middleware(request: NextRequest) {
const { pathname, hostname, protocol } = request.nextUrl;
const method = request.method;
let target: string | null = null;
const script = SCRIPT_RE.exec(pathname);
if (script && (method === 'GET' || method === 'HEAD')) {
target = `${SOURCREAM_ORIGIN}/t/${script[1]}.js?b=${BASE_PATH}`;
} else if ((pathname === `${BASE_PATH}/c` || pathname === `${BASE_PATH}/l`) && (method === 'POST' || method === 'OPTIONS')) {
target = SOURCREAM_ORIGIN + pathname.slice(BASE_PATH.length);
}
if (!target) return new NextResponse('Not found', { status: 404 });
// Build the forwarded headers from scratch: these replace the browser's headers.
const headers = new Headers();
headers.set('x-sourcream-proxy', '1');
headers.set('x-forwarded-host', hostname);
headers.set('x-forwarded-proto', protocol.replace(':', ''));
for (const name of FORWARD) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
const visitor = request.cookies.get('_sc_id')?.value;
if (visitor && /^[A-Za-z0-9._~-]{1,128}$/.test(visitor)) headers.set('cookie', `_sc_id=${visitor}`);
// The visitor's address on collect and lead calls (see the note below the code).
const ip = request.headers.get('x-real-ip')?.trim();
if (method === 'POST' && ip && /^[0-9A-Fa-f:.]{2,45}$/.test(ip)) headers.set('x-sourcream-client-ip', ip);
return NextResponse.rewrite(target, { request: { headers } });
}
export const config = { matcher: '/_sc/:path*' };
The visitor's address comes from x-real-ip. On Vercel, Vercel sets that header to the address the visitor connected from and replaces any value the visitor sends. Self hosted, it holds whatever the server in front of Next.js puts there: have your reverse proxy set it on every request (nginx: proxy_set_header X-Real-IP $remote_addr;), or delete the two x-real-ip lines when nothing in front of Next.js sets it, since a value there would then come from the visitor. Without the two lines there is no per visitor limit (above). On Vercel, the calls leave from Vercel's shared addresses, so your site shares those addresses' buckets with other senders (shared addresses). Self hosted, it does not when nobody else sends from your server's identity: an IPv4 address of its own, with the server reaching Sourcream over IPv4 only, or a whole IPv6 /56 of its own.
Next.js forwards the response, including Set-Cookie, unchanged. Deploy, then run the checks above and confirm that the Cookie header Sourcream receives holds only _sc_id (our support can confirm from our side if you email [email protected]).
Option 4: Vercel without Next.js (vercel.json)
Vercel's routes with transform rules can proxy the three paths and add the headers. Add this to vercel.json (keep your existing settings):
{
"routes": [
{
"src": "^/_sc/t/([A-Za-z0-9_-]{1,64})\\.js$",
"methods": ["GET", "HEAD"],
"dest": "https://cdn.sourcream.ai/t/$1.js?b=/_sc",
"transforms": [
{ "type": "request.headers", "op": "set", "target": { "key": "x-sourcream-proxy" }, "args": "1" },
{ "type": "request.headers", "op": "set", "target": { "key": "x-forwarded-host" }, "args": "$host" },
{ "type": "request.headers", "op": "set", "target": { "key": "x-forwarded-proto" }, "args": "https" },
{ "type": "request.headers", "op": "delete", "target": { "key": "cookie" } }
]
},
{
"src": "^/_sc/(c|l)$",
"methods": ["POST", "OPTIONS"],
"dest": "https://cdn.sourcream.ai/$1",
"transforms": [
{ "type": "request.headers", "op": "set", "target": { "key": "x-sourcream-proxy" }, "args": "1" },
{ "type": "request.headers", "op": "set", "target": { "key": "x-forwarded-host" }, "args": "$host" },
{ "type": "request.headers", "op": "set", "target": { "key": "x-forwarded-proto" }, "args": "https" },
{ "type": "request.headers", "op": "delete", "target": { "key": "x-sourcream-client-ip" } }
]
},
{ "src": "^/_sc(/.*)?$", "status": 404 }
]
}
The limit: Vercel's transform rules can delete the whole Cookie header but cannot keep one cookie and drop the rest. The script route drops cookies entirely. The collect and lead routes must keep the header so _sc_id reaches Sourcream, which means every cookie on your domain travels with those two requests. Sourcream reads _sc_id only and does not use or store the others. If your domain carries sensitive cookies, such as a logged in app on the same hostname, use the Next.js option (or Vercel Routing Middleware with the same code), which forwards _sc_id alone.
The second limit: transform rules can only set fixed values (with path captures, $host and environment variables), so vercel.json cannot send the visitor's address in X-Sourcream-Client-IP. Sourcream then has no per visitor limit for your site: all its visitors share your site's bucket for the Vercel address their calls leave from (6,000 calls a minute on each endpoint). Those addresses are shared: other Vercel sites, and anyone else who sends from the same address, count in the same buckets (shared addresses). Real visitors alone rarely come near the limit. The collect and lead route deletes any X-Sourcream-Client-IP a visitor sends. For a per visitor limit, use the Next.js option (or Vercel Routing Middleware with the same code), which sends the visitor's address from x-real-ip.
Option 5: Netlify (netlify.toml)
Netlify sends custom headers with proxy rules only when they are defined in netlify.toml; the _redirects file cannot add headers. Replace www.yourdomain.com with the hostname your site is served on.
[[redirects]]
from = "/_sc/t/:file"
to = "https://cdn.sourcream.ai/t/:file?b=/_sc"
status = 200
force = true
headers = { X-Sourcream-Proxy = "1", X-Forwarded-Host = "www.yourdomain.com", X-Forwarded-Proto = "https" }
[[redirects]]
from = "/_sc/c"
to = "https://cdn.sourcream.ai/c"
status = 200
force = true
headers = { X-Sourcream-Proxy = "1", X-Forwarded-Host = "www.yourdomain.com", X-Forwarded-Proto = "https" }
[[redirects]]
from = "/_sc/l"
to = "https://cdn.sourcream.ai/l"
status = 200
force = true
headers = { X-Sourcream-Proxy = "1", X-Forwarded-Host = "www.yourdomain.com", X-Forwarded-Proto = "https" }
Put these rules above any catch all rule. Netlify answers 404 for other /_sc/ paths because no file exists there. Like Vercel, Netlify cannot reduce the Cookie header to _sc_id only; the same note applies. The hostname is fixed in the file, so serve your site on one hostname (Netlify redirects the other to it by default).
Netlify's proxy rules also add fixed header values only, so netlify.toml cannot send the visitor's address in X-Sourcream-Client-IP. As with Vercel, Sourcream then has no per visitor limit for your site, and all its visitors share your site's bucket for the Netlify address their calls leave from (6,000 calls a minute on each endpoint). Those addresses are shared too: other Netlify sites, and anyone else who sends from the same address, count in the same buckets (shared addresses). The rules pass on headers the visitor sends, so an X-Sourcream-Client-IP a visitor adds reaches Sourcream; your other visitors send none, so it only counts calls that carry that same value.
A Netlify Edge Function can send it, forwards only _sc_id and works on every hostname. To use one instead of the three rules above, remove the rules and add netlify/edge-functions/sourcream.ts:
import type { Config, Context } from '@netlify/edge-functions';
const SOURCREAM_ORIGIN = 'https://cdn.sourcream.ai';
const BASE_PATH = '/_sc';
const SCRIPT_RE = /^\/_sc\/t\/([A-Za-z0-9_-]{1,64})\.js$/;
const FORWARD = ['origin', 'user-agent', 'content-type', 'access-control-request-method', 'access-control-request-headers'];
export default async (request: Request, context: Context) => {
const url = new URL(request.url);
const method = request.method;
let target: string | null = null;
const script = SCRIPT_RE.exec(url.pathname);
if (script && (method === 'GET' || method === 'HEAD')) {
target = `${SOURCREAM_ORIGIN}/t/${script[1]}.js?b=${BASE_PATH}`;
} else if ((url.pathname === `${BASE_PATH}/c` || url.pathname === `${BASE_PATH}/l`) && (method === 'POST' || method === 'OPTIONS')) {
target = SOURCREAM_ORIGIN + url.pathname.slice(BASE_PATH.length);
}
if (!target) return new Response('Not found', { status: 404 });
// Built from scratch: only these headers reach Sourcream.
const headers = new Headers();
headers.set('x-sourcream-proxy', '1');
headers.set('x-forwarded-host', url.hostname);
headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
for (const name of FORWARD) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
if (method === 'POST') {
const visitor = context.cookies.get('_sc_id');
if (visitor && /^[A-Za-z0-9._~-]{1,128}$/.test(visitor)) headers.set('cookie', `_sc_id=${visitor}`);
// The address the visitor connected to Netlify from.
if (/^[0-9A-Fa-f:.]{2,45}$/.test(context.ip)) headers.set('x-sourcream-client-ip', context.ip);
}
return fetch(target, {
method,
headers,
body: method === 'POST' ? await request.arrayBuffer() : undefined,
redirect: 'manual',
});
};
export const config: Config = { path: '/_sc/*' };
context.ip is the address Netlify saw the visitor connect from. The function passes Sourcream's response back unchanged, including Set-Cookie. Its calls still leave from Netlify's shared addresses, like the rules above, so your site shares those addresses' buckets with other senders (shared addresses).
Option 6: nginx
In the http { } block (for example /etc/nginx/conf.d/sourcream-map.conf):
# Forward only the _sc_id cookie to Sourcream.
map $cookie__sc_id $sourcream_cookie {
"~^[A-Za-z0-9._~-]{1,128}$" "_sc_id=$cookie__sc_id";
default "";
}
A shared snippet, /etc/nginx/snippets/sourcream-proxy.conf:
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_redirect off;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
client_max_body_size 32k;
proxy_set_header Host cdn.sourcream.ai;
proxy_set_header Connection "";
proxy_set_header X-Sourcream-Proxy 1;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $sourcream_cookie;
proxy_set_header X-Sourcream-Client-IP $remote_addr;
proxy_set_header Authorization "";
proxy_set_header Referer "";
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
Inside your site's server { } block:
location ~ ^/_sc/t/[A-Za-z0-9_-]{1,64}\.js$ {
limit_except GET { deny all; }
include snippets/sourcream-proxy.conf;
rewrite ^/_sc/t/(.+)$ /t/$1?b=/_sc? break;
proxy_pass https://cdn.sourcream.ai;
}
location = /_sc/c {
limit_except POST OPTIONS { deny all; }
include snippets/sourcream-proxy.conf;
proxy_pass https://cdn.sourcream.ai/c;
}
location = /_sc/l {
limit_except POST OPTIONS { deny all; }
include snippets/sourcream-proxy.conf;
proxy_pass https://cdn.sourcream.ai/l;
}
location /_sc/ {
return 404;
}
limit_except GET also allows HEAD. An empty proxy_set_header value removes that header, so visitors without _sc_id send no cookie at all. nginx passes Set-Cookie and the other response headers through by default. Run nginx -t before reloading.
X-Sourcream-Client-IP is set from $remote_addr, the address that connected to nginx, which replaces any value the visitor sends. When visitors reach nginx directly, that is their own address. When nginx sits behind Cloudflare or a load balancer, $remote_addr is that service's address unless you set up nginx's realip module: set_real_ip_from with the service's address ranges, and real_ip_header CF-Connecting-IP; behind Cloudflare or real_ip_header X-Forwarded-For; behind your own load balancer. Without it, every visitor arriving through the same Cloudflare address would share one limit (above); if you cannot set it up, change the line to proxy_set_header X-Sourcream-Client-IP "";, which sends none.
Option 7: Apache
Needs mod_proxy, mod_proxy_http, mod_ssl, mod_rewrite and mod_headers. In the site's <VirtualHost> (server configuration, not .htaccess):
SSLProxyEngine on
ProxyPreserveHost Off
RewriteEngine on
# Remember the visitor's _sc_id, if any.
RewriteCond %{HTTP_COOKIE} (?:^|;\s*)_sc_id=([A-Za-z0-9._~-]{1,128})(?:;|$)
RewriteRule ^/_sc/ - [E=SC_ID:%1]
# Remember the visitor's address.
RewriteRule ^/_sc/ - [E=SC_CLIENT_IP:%{REMOTE_ADDR}]
RewriteCond %{REQUEST_METHOD} ^(GET|HEAD)$
RewriteRule ^/_sc/t/([A-Za-z0-9_-]{1,64})\.js$ https://cdn.sourcream.ai/t/$1.js?b=/_sc [P,L]
RewriteCond %{REQUEST_METHOD} ^(POST|OPTIONS)$
RewriteRule ^/_sc/(c|l)$ https://cdn.sourcream.ai/$1 [P,L]
RewriteRule ^/_sc(/|$) - [R=404,L]
<Location "/_sc/">
RequestHeader unset Cookie
RequestHeader set Cookie "_sc_id=%{SC_ID}e" env=SC_ID
RequestHeader unset X-Sourcream-Client-IP
RequestHeader set X-Sourcream-Client-IP "%{SC_CLIENT_IP}e" env=SC_CLIENT_IP
RequestHeader set X-Sourcream-Proxy "1"
RequestHeader set X-Forwarded-Proto "https"
RequestHeader unset Authorization
RequestHeader unset Referer
</Location>
ProxyPreserveHost Off (the default) makes Apache send Host: cdn.sourcream.ai, which Sourcream needs. mod_proxy adds X-Forwarded-Host with your hostname by itself, so do not set it again. A query string in the rewrite target replaces the browser's, so ?b=/_sc is always the one sent. X-Sourcream-Client-IP is the address that connected to Apache (REMOTE_ADDR), never a value the visitor sends. When Apache sits behind Cloudflare or a load balancer, that is the service's address unless mod_remoteip replaces it with the visitor's (RemoteIPHeader CF-Connecting-IP or RemoteIPHeader X-Forwarded-For, with RemoteIPTrustedProxy listing the service's address ranges). Without it, every visitor arriving through the same Cloudflare address would share one limit (above); if you cannot set it up, delete the RequestHeader set X-Sourcream-Client-IP line and keep the unset line, which sends none. Check with apachectl configtest before reloading. Most shared hosting does not allow mod_proxy; if yours does not, use standard mode.
Option 8: WordPress
The Sourcream plugin includes a proxy through the WordPress REST API, so there is nothing to configure on the server. It sends each visitor's address in X-Sourcream-Client-IP: the address that connected to your server, or, when you choose one under Trusted proxy header, the address your Cloudflare or load balancer reports. Its calls leave from your web server's address, which on shared hosting is shared with other sites, and over IPv6 Sourcream counts the server's /56, which is often shared even when the address is not (shared addresses). See WordPress. If your site's DNS is on Cloudflare, you can use the Cloudflare Worker (option 1 or 2) instead of the plugin's proxy: WordPress shows how.
Troubleshooting
- The Install tab never shows first-party: the
X-Sourcream-Proxy: 1header is not reaching us. Check the proxy adds it, and that the script tag uses the proxied path. - 403 on
/_sc/c: theOrigin(orX-Forwarded-Host) is not the site's domain or one of its own domains. Add staging and alternate hostnames under the site's own domains. _sc_idis not set: the response'sSet-Cookieis being stripped (some CDNs and caching plugins remove it), or the POST is being cached. Exclude/_sc/from caching.- 429 on
/_sc/cor/_sc/lfor many visitors at once: check that your proxy sends each visitor's own address inX-Sourcream-Client-IP(above), not the address of a CDN or load balancer in front of it. On Vercel, Netlify or shared hosting, or from an IPv6 /56 other servers share, other senders on the same address count in the same limits as your site (shared addresses); the Cloudflare Worker avoids that, though calls anyone sends through its public route still count in your zone's limits. A 429 whoseRetry-Aftercounts down to the next UTC hour (not the usual 60 seconds) means your site's hourly cap is full: from real traffic (email [email protected]) or from someone filling it on purpose, which anyone can do whatever proxy you use. Leads sent to the webhook intake are not affected (hourly caps). - Script 404 through the proxy: the site key in the path is wrong, or the proxy rewrites the path incorrectly. Compare with the standard script URL on the Install tab.
Questions: [email protected]