Freestyle Docs

Freestyle / Guides

Gate A Sandbox With Hexclave

Put many preview VMs behind one Hexclave (Stack Auth) sign-in using forward auth, with a single trusted callback and a per-host session handoff.

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.

The cross-origin sign-in handoff A browser request with no session reaches the Freestyle edge and its check endpoint. With no session the edge redirects to your auth origin, which signs the visitor in, mints a one-time code, and returns to a callback on the protected domain. The edge redeems the code, sets a session cookie, and proxies the request to the VM. All redirects pass through the browser. Browser holds cookie Freestyle edge + your /check Your auth origin sign in here VM 1 request 2 302 to sign in 3 callback + code 4 set cookie, proxy Every redirect passes through the browser; the cookie is set on the protected domain.

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/js
bun add freestyle @stackframe/js
npm 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.

esc