Hexclave (formerly Stack Auth) is a self-hosted user-infrastructure platform; forward auth decides at the Freestyle edge whether each request reaches your VM. This gates a fleet of preview hosts behind one Hexclave sign-in. It is the cross-origin handoff from Protect A Sandbox With Forward Auth, the same shape as the WorkOS guide.
Hexclave validates a sign-in’s return URL against the project’s trusted domains,
so rather than trust every dynamic preview host, point Hexclave at one
central callback and hand the session off to the specific preview host yourself
with a one-time code. Each host gets its own __Host- cookie; a cookie shared
across a parent domain would leak between tenants. Stand up Hexclave with Run
Hexclave In A Sandbox, or use an existing
deployment.
Install The SDK
pnpm add freestyle @stackframe/jsbun add freestyle @stackframe/jsnpm install freestyle @stackframe/js export FREESTYLE_API_KEY="your-freestyle-key"
export HEXCLAVE_PROJECT_ID="…"
export HEXCLAVE_PUBLISHABLE_CLIENT_KEY="…"
export HEXCLAVE_SECRET_SERVER_KEY="…"
export HEXCLAVE_BASE_URL="https://auth.example.com" # your Hexclave deployment
Add https://auth.example.com/hexclave/callback as a trusted return URL in your
Hexclave project.
Two Services, One Store
- A central auth origin (
auth.example.com), a normal web app the browser reaches directly, holding the one return URL Hexclave trusts. - 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 -> { tokens, host }, a couple of minutes TTL. The rule-to-host map is also a store
you write to as you create each rule.
import { StackServerApp } from "@stackframe/js";
const stack = new StackServerApp({
projectId: process.env.HEXCLAVE_PROJECT_ID!,
publishableClientKey: process.env.HEXCLAVE_PUBLISHABLE_CLIENT_KEY!,
secretServerKey: process.env.HEXCLAVE_SECRET_SERVER_KEY!,
baseUrl: process.env.HEXCLAVE_BASE_URL!,
tokenStore: "memory",
});
const SERVICE_SECRET = process.env.FORWARD_AUTH_SECRET!;
const SESSION_COOKIE = "__Host-hexclave-session";
type Tokens = { accessToken: string; refreshToken: string };
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>;
declare const store: {
put(code: string, v: { tokens: Tokens; host: string }, o: { ttlMs: number }): Promise<void>;
take(code: string): Promise<{ tokens: Tokens; 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, signs in on Hexclave, and returns to the one trusted
/hexclave/callback. That route reads the freshly established session, takes its
token pair, and hands it 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") ?? "/" });
const signIn = new URL("/handler/sign-in", process.env.HEXCLAVE_BASE_URL);
signIn.searchParams.set(
"after_auth_return_to",
`https://auth.example.com/hexclave/callback?state=${encodeURIComponent(state)}`,
);
return Response.redirect(signIn.toString(), 302);
}
// GET https://auth.example.com/hexclave/callback?state
// Hexclave has set its session on this origin; read the token pair to hand off.
export async function hexclaveCallback(request: Request): Promise<Response> {
const url = new URL(request.url);
const { host, returnTo } = verify(url.searchParams.get("state")!);
const tokens = await sessionTokens(request); // access + refresh from the session
if (!tokens) return new Response(null, { status: 401 });
const code = crypto.randomUUID();
await store.put(code, { tokens, 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 token pair Hexclave set on this origin: (await stack.getUser()).getAuthJson()
// returns { accessToken, refreshToken }. How this request's session reaches
// getUser is framework-specific (automatic in Next.js via the mounted handler).
declare function sessionTokens(request: Request): Promise<Tokens | null>;
The Per-Host Authorizer
Freestyle calls /check for every request. A live user allows; otherwise send
the visitor to the central origin. The /_auth/callback arrives on the preview
host, 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 cookie = encodeURIComponent(JSON.stringify(handoff.tokens));
const headers = new Headers({ location: safePath(forwarded.searchParams.get("return") ?? "/", rule.host) });
headers.append("set-cookie", `${SESSION_COOKIE}=${cookie}; Path=/; HttpOnly; Secure; SameSite=Lax`);
return new Response(null, { status: 302, headers });
}
// Otherwise validate the session cookie; a live user allows the request.
const tokens = parseTokens(readCookie(request, SESSION_COOKIE));
if (tokens) {
try {
const user = await stack.getUser({ tokenStore: tokens });
if (user) return new Response(null, { status: 204, headers: { "x-hexclave-user-id": user.id } });
} catch {
// A tampered or expired token pair can throw; 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 parseTokens(cookie: string | null): Tokens | null {
if (!cookie) return null;
try {
const t = JSON.parse(cookie);
return t.accessToken && t.refreshToken ? t : null;
} catch {
return null;
}
}
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 "/";
}
}
Hexclave’s exact sign-in handler and how you read the session’s token pair depend
on its config; keep the Hexclave docs open for the
getUser and handler options.
Wire Each Rule
Per preview host, attach one forwardAuth config and record the rule id.
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-hexclave-user-id"],
protectedCookies: ["__Host-hexclave-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 Hexclave, back to that host with a one-time code, and land signed in.