WorkOS AuthKit hosts sign-in and issues sessions; forward auth decides at the Freestyle edge whether each request reaches your VM. This gates a fleet of preview hosts behind one WorkOS sign-in. Read Protect A Sandbox With Forward Auth first; this is the cross-origin handoff.
The important constraint: WorkOS redirect URIs are exact matches, with no
wildcards. You can not register a callback per preview host or per customer
domain. So register one callback on a central auth origin, and after WorkOS
returns, hand the session off to the specific preview host yourself with a
one-time code. That final leg is your own code, so any number of hosts and custom
domains work, and each gets its own __Host- cookie (a cookie shared across a
parent domain would leak between tenants).
Install The SDK
pnpm add freestyle @workos-inc/nodebun add freestyle @workos-inc/nodenpm install freestyle @workos-inc/node export FREESTYLE_API_KEY="your-freestyle-key"
export WORKOS_API_KEY="sk_live_…"
export WORKOS_CLIENT_ID="client_…"
# 32+ byte secret that seals the session cookie:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
export WORKOS_COOKIE_PASSWORD="…"
In WorkOS, register exactly one redirect URI: https://auth.example.com/workos/callback.
Two Services, One Store
- A central auth origin (
auth.example.com), a normal web app the browser reaches directly. It runs WorkOS and is the one URI WorkOS knows. - A per-host authorizer Freestyle calls for every request to a preview host.
They share a short-lived, single-use code store (Redis, KV, a table): code -> { sealedSession, host }, a couple of minutes TTL. Since preview hosts are
created on the fly, the rule-to-host map is also a store you write to as you
create each rule, not a hardcoded object.
import { WorkOS } from "@workos-inc/node";
const CLIENT_ID = process.env.WORKOS_CLIENT_ID!;
// Pass clientId: loadSealedSession(...).authenticate() needs it to fetch the
// JWKS and verify the session, and throws "Missing client ID" without it.
const workos = new WorkOS(process.env.WORKOS_API_KEY!, { clientId: CLIENT_ID });
const COOKIE_PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!;
const SERVICE_SECRET = process.env.FORWARD_AUTH_SECRET!;
const CALLBACK_URI = "https://auth.example.com/workos/callback";
const SESSION_COOKIE = "__Host-workos-session";
// Freestyle authenticates itself with the header on the forwardAuth config.
const trustedEdge = (r: Request) =>
r.headers.get("authorization") === `Bearer ${SERVICE_SECRET}`;
// Resolve the request's host from the trusted TLS rule id, never
// x-forwarded-host. See /vms/forward-auth#behind-a-reverse-proxy.
declare function lookupRule(ruleId: string): Promise<{ host: string } | null>;
// The single-use handoff store, and a signed carrier for the target host.
declare const store: {
put(code: string, v: { sealedSession: string; host: string }, o: { ttlMs: number }): Promise<void>;
take(code: string): Promise<{ sealedSession: string; host: string } | null>;
};
declare function sign(v: object): string;
declare function verify(token: string): { host: string; returnTo: string };
function readCookie(request: Request, name: string): string | null {
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
const [k, ...v] = part.trim().split("=");
if (k === name) return decodeURIComponent(v.join("="));
}
return null;
}
The Central Auth Origin
The browser hits /start (sent there by a preview host below), signs in on
WorkOS, and returns to the one registered /workos/callback. That route
exchanges the code and hands the session to the target preview host through a
one-time code.
// GET https://auth.example.com/start?host=<previewHost>&return=<path>
export function start(request: Request): Response {
const url = new URL(request.url);
const state = sign({ host: url.searchParams.get("host")!, returnTo: url.searchParams.get("return") ?? "/" });
return Response.redirect(
workos.userManagement.getAuthorizationUrl({
provider: "authkit",
clientId: CLIENT_ID,
redirectUri: CALLBACK_URI,
state,
}),
302,
);
}
// GET https://auth.example.com/workos/callback?code&state
export async function workosCallback(request: Request): Promise<Response> {
const url = new URL(request.url);
const { host, returnTo } = verify(url.searchParams.get("state")!);
const { sealedSession } = await workos.userManagement.authenticateWithCode({
clientId: CLIENT_ID,
code: url.searchParams.get("code")!,
session: { sealSession: true, cookiePassword: COOKIE_PASSWORD },
});
// Cross to the preview host with a one-time code it will exchange.
const code = crypto.randomUUID();
await store.put(code, { sealedSession: sealedSession!, host }, { ttlMs: 120_000 });
const back = new URL(`https://${host}/_auth/callback`);
back.searchParams.set("code", code);
back.searchParams.set("return", returnTo);
return Response.redirect(back.toString(), 302);
}
The Per-Host Authorizer
Freestyle calls /check for every request. A valid session allows; otherwise
send the visitor to the central origin. The /_auth/callback arrives on the
preview host (through Freestyle), redeems the one-time code, and sets the cookie.
// The one endpoint Freestyle calls for every request to a protected host, with
// the original request in X-Forwarded-*. The handoff callback arrives here too
// (through Freestyle), so branch on the path.
export async function check(request: Request): Promise<Response> {
if (!trustedEdge(request)) return new Response(null, { status: 401 });
const rule = await lookupRule(request.headers.get("x-freestyle-tls-rule-id") ?? "");
if (!rule) return new Response(null, { status: 404 });
const forwarded = new URL(request.headers.get("x-forwarded-uri") ?? "/", `https://${rule.host}`);
// Handoff callback: redeem the host-bound one-time code and set the cookie.
if (forwarded.pathname === "/_auth/callback") {
const handoff = await store.take(forwarded.searchParams.get("code") ?? ""); // single-use
if (!handoff || handoff.host !== rule.host) return new Response(null, { status: 400 });
const headers = new Headers({ location: safePath(forwarded.searchParams.get("return") ?? "/", rule.host) });
headers.append(
"set-cookie",
`${SESSION_COOKIE}=${encodeURIComponent(handoff.sealedSession)}; Path=/; HttpOnly; Secure; SameSite=Lax`,
);
return new Response(null, { status: 302, headers });
}
// Otherwise validate the session cookie; a valid session allows the request.
const cookie = readCookie(request, SESSION_COOKIE);
if (cookie) {
try {
const result = await workos.userManagement
.loadSealedSession({ sessionData: cookie, cookiePassword: COOKIE_PASSWORD })
.authenticate();
if (result.authenticated) {
return new Response(null, { status: 204, headers: { "x-workos-user-id": result.user.id } });
}
} catch {
// A tampered or truncated cookie throws rather than returning
// { authenticated: false }; treat it as signed out.
}
}
// No session: hand off to the central origin, keeping the path they wanted.
const start = new URL("https://auth.example.com/start");
start.searchParams.set("host", rule.host);
start.searchParams.set("return", forwarded.pathname + forwarded.search);
return Response.redirect(start.toString(), 302);
}
function safePath(value: string, host: string): string {
try {
const url = new URL(value, `https://${host}`);
return url.origin === `https://${host}` ? `${url.pathname}${url.search}` : "/";
} catch {
return "/";
}
}
Wire Each Rule
Per preview host, attach one forwardAuth config and record the rule id so
lookupRule can resolve it. One config can protect many rules.
const auth = await freestyle.tls.forwardAuth.create({
url: "https://auth.example.com/check",
headers: { authorization: `Bearer ${process.env.FORWARD_AUTH_SECRET}` },
timeoutMs: 1500,
authResponseHeaders: ["x-workos-user-id"],
protectedCookies: ["__Host-workos-session"],
});
const rule = await freestyle.tls.rules.create({
action: "allow",
domain: previewHost,
source: { public: true },
destination: { vmId, port: 3000 },
forwardAuth: { id: auth.id },
});
await saveRule(rule.id, { host: previewHost }); // what lookupRule reads
Visit any preview host signed out: you go to the central origin, through WorkOS,
back to that host with a one-time code, and land signed in. Keep the
WorkOS session docs open for
exact getAuthorizationUrl, authenticateWithCode, and loadSealedSession
signatures.