Skip to content

Same-Origin Proxy

Browser ──/cms/*──▶ Your Astro server ──server-side request──▶ Kenresoft API Worker

The proxy is an API and authentication gateway for a frontend. It is not the CMS admin UI, and it does not expose the admin API.

By default the API’s session cookie belongs to the API’s own origin. Your website’s server never sees it, so no server-side “is this visitor signed in?” checks, and browsers that block third-party cookies drop it entirely. With the proxy, the browser talks only to your site (/cms/*), your server forwards to the API, and the API’s Set-Cookie lands on your site’s own host. Both the browser and SSR then see the cookie, on any domain layout.

Without the proxy (“direct mode”), sign-in was measured working in Chromium and failing in WebKit and in Firefox with third-party cookies blocked. Use the proxy for any site with accounts.

The create astro command and the new-site starter both create it at src/pages/cms/[...path].ts. The generated file for Cloudflare:

src/pages/cms/[...path].ts
import type { APIRoute } from 'astro';
import { env } from 'cloudflare:workers';
import { createCmsProxy } from '@kenresoft-cms/astro';
export const prerender = false;
// The Worker's runtime variable first, then the value from the build.
const cmsUrl = () =>
(typeof env.PUBLIC_KENRESOFT_CMS_URL === 'string' && env.PUBLIC_KENRESOFT_CMS_URL) ||
import.meta.env.PUBLIC_KENRESOFT_CMS_URL;
export const ALL: APIRoute = ({ request }) =>
createCmsProxy({
url: cmsUrl(),
trustedProxySecret: typeof env.TRUSTED_PROXY_SECRET === 'string' ? env.TRUSTED_PROXY_SECRET : undefined,
})(request);

The Node version reads process.env the same way. Reading the URL at runtime first means a build made without PUBLIC_KENRESOFT_CMS_URL (for example a Cloudflare Git build without the Build variable) still works when the deployed Worker has the variable. Sites created before @kenresoft-cms/create 0.3.3 read it at build time only; run --update to get this version.

If the proxy has no URL at all, it answers every request with 503 ({"error":"The CMS connection is not configured"}) and logs which variable is missing.

Option Default Purpose
url (required) The CMS API’s real base URL
basePath /cms The path prefix the proxy is mounted at
trustedProxySecret none Must equal the API’s TRUSTED_PROXY_SECRET
fetch global fetch Override, for testing

The proxy needs on-demand (server) rendering and a deployment adapter.

Only these upstream paths are allowed. Anything else, including /api/v1/admin/* and the system routes, returns 404 without contacting the API:

  • /api/v1/auth/*
  • /api/v1/public/*
  • /api/v1/account/* (the signed-in account’s own form submissions; @kenresoft-cms/astro 0.8.0 and later)
  • /api/plugins/<id>/public/*

A request outside the base path also returns 404. Path traversal is collapsed before the check, so .. cannot escape the allow-list.

  • Trailing slash: a trailing slash is dropped before forwarding, because the API rejects paths that end in one. This matters on sites with Astro’s trailingSlash: 'always' (fixed in @kenresoft-cms/astro 0.6.1; older versions fail every call). See Troubleshooting.
  • Body: forwarded byte for byte for every method except GET and HEAD, so multipart/form-data uploads keep their boundary and files intact.
  • Request headers forwarded: cookie, content-type, accept, accept-language, idempotency-key, origin, user-agent, x-turnstile-token. Nothing else is passed on.
  • Response: status, body and all headers pass through, including every Set-Cookie, except content-encoding, content-length, transfer-encoding and connection, which describe bytes fetch already decoded.
  • Redirects (for example the email-verification link’s callback) are returned to the browser untouched, not followed.
  • The cookie is set by the API with SameSite=None; Secure, and passes to the browser under your site’s host.
  • The proxy forwards the browser’s Origin header on sign-in and form requests, and Better Auth checks it. Your site’s origin must therefore be in the API’s CORS_ORIGINS, even though the browser only calls your own domain. Plain server-side content reads send no Origin, so a site can render pages and still fail its first sign-in. See Authentication and CORS.
  • BETTER_AUTH_URL must stay the API’s own URL, not your site’s.
// Browser code: talk to your own origin
const browserCms = createKenresoftClient({ url: '/cms' });
// Server (SSR) code: call the API directly, forwarding the first-party cookie
const cmsForRequest = createKenresoftClient({
url: import.meta.env.PUBLIC_KENRESOFT_CMS_URL,
cookies: Astro.request.headers.get('cookie'),
});

The managed src/lib/kenresoft.ts exports cms, cmsForRequest(request) and browserCms. Call the state-changing auth methods from browser code (a <script> or client island), since the cookie must land in the visitor’s own cookie jar.

Behind the proxy, the API sees your Astro Worker as the client, so all visitors share one rate-limit bucket. To keep per-visitor limits accurate, set the same secret on both sides. The secret is a random string you generate yourself. It is not issued by Cloudflare or Kenresoft.

  1. Generate it:

    Terminal window
    node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  2. Set it as a Worker secret in the CMS project, then in the Astro site project (run the command in each project directory and paste the same value):

    Terminal window
    wrangler secret put TRUSTED_PROXY_SECRET
  3. Confirm both Workers have it with wrangler secret list.

The proxy sends x-kenresoft-proxy-secret and x-kenresoft-client-ip (taken from cf-connecting-ip, or the first x-forwarded-for entry) when it has both a secret and a client IP. The API trusts the forwarded IP only if the secret matches, using a constant-time comparison. Unset on the API, or a mismatch, and the headers are ignored and everyone shares one limit, with no error.

The generated proxy reads the secret from the runtime environment: the Worker env on Cloudflare, process.env on Node. No code change is needed. Never expose it through a PUBLIC_ variable and never commit it. For local development, put it in .dev.vars (Cloudflare) or your shell environment, and keep that file gitignored. See Rate limiting.

If your site is a Cloudflare Worker calling a CMS API Worker in the same account, add global_fetch_strictly_public to your Wrangler compatibility_flags. Without it, the call fails with error code: 1042, which surfaces as a 404. This flag is only needed on Cloudflare; a site on another host does not need it. See Astro deployment.

The browser can call the API directly (Browser → CMS API). It is simpler but browser-only, depends on cross-site cookies, and gives server-rendered pages no session. Add your origin to CORS_ORIGINS and call the mutating auth methods from the browser.