Freestyle Docs

Freestyle / Guides

Protect A Sandbox With Forward Auth

Patterns for gating a public VM behind your own auth: machine tokens, session cookies, and a cross-origin sign-in handoff, and which to reach for.

Forward auth lets your code decide whether each public HTTP request reaches a VM. Freestyle terminates HTTPS, asks an endpoint you run, and forwards only allowed requests. This guide is the map: the three shapes a protected flow takes, and how to pick one. The provider guides (WorkOS, Hexclave) wire these patterns to a real identity provider.

The Shape Of Every Authorizer

Create one reusable forwardAuth config and attach it to a public HTTP TLS rule:

const auth = await freestyle.tls.forwardAuth.create({
  url: "https://auth.example.com/freestyle/check",
  // Authenticates Freestyle to your endpoint. Write-only, read back as "***".
  headers: { authorization: "Bearer service-to-service-secret" },
  timeoutMs: 1500,
  // Identity your endpoint established, copied onto the VM request.
  authResponseHeaders: ["x-user-id", "x-team-id"],
  // Cookies the authorizer may read but the VM never sees.
  protectedCookies: ["__Host-session"],
});

await freestyle.tls.rules.create({
  action: "allow",
  domain: "preview.example.com",
  source: { public: true },
  destination: { vmId: "vm-123456", port: 3000 },
  forwardAuth: { id: auth.id },
});

For each request, Freestyle sends your endpoint a bodyless GET carrying the caller’s Authorization and Cookie, your static headers, the request metadata in X-Forwarded-*, and the matched rule id in X-Freestyle-TLS-Rule-Id. The status code is the whole contract:

  • 2xx allows. Any authResponseHeaders you set replace the caller’s values on the request that reaches the VM.
  • 3xx / 4xx is returned to the browser verbatim. Freestyle does not follow redirects, so a 302 sends a signed-out visitor into a login flow.
  • Timeout, 5xx, or an unreachable endpoint fails closed: 503, and the VM is never reached.

An authorizer is one handler that maps a request to 204, 302, or 403. The patterns differ only in how it decides, and who runs the login page.

Pattern 1: Machine Token

Forward auth with a machine token A client sends a request with a bearer token to the Freestyle edge. The edge asks your check endpoint, which compares the token and answers 204 to allow or 403 to deny. On allow the edge proxies the request to the VM; on deny the 403 is returned to the client. Client + Bearer token Freestyle edge TLS rule Your /check compare token VM check 204 / 403 on allow

The caller presents a credential on every request; the authorizer says yes or no. No browser, no cookies, no redirects. Use it for programmatic access: a webhook receiver, an internal API, a CI job reaching a preview.

// POST https://auth.example.com/freestyle/check
export function check(request: Request): Response {
  const presented = request.headers.get("authorization");
  const ok = presented && timingSafeEqual(presented, `Bearer ${API_TOKEN}`);
  return new Response(null, { status: ok ? 204 : 403 });
}

There is no UI to show: a caller with the token gets 204; everyone else gets 403. The rest of this guide is the case where the caller is a person.

Forward auth with a session cookie A browser request reaches the Freestyle edge, which asks your check endpoint. The endpoint reads the session cookie. A valid session returns 204 and the request is proxied to the VM. A missing session returns 302 to the provider's hosted login, which signs the visitor in and sends them back to the protected domain with the cookie set. Browser + session cookie Freestyle edge TLS rule Your /check verify cookie VM 204 valid 302 no session sign in → back with cookie

The visitor is a person, and your provider sets a cookie the authorizer can read on the protected domain. Check the cookie; if valid, 204; if not, 302 to the provider’s hosted login, which returns the visitor with the cookie set.

// GET https://auth.example.com/freestyle/check
export async function check(request: Request): Promise<Response> {
  const session = readCookie(request, "__Host-session");
  const user = session ? await provider.verifySession(session) : null;
  if (!user) {
    const returnTo = reconstructUrl(request); // from X-Forwarded-* (see below)
    return Response.redirect(provider.loginUrl({ returnTo }), 302);
  }
  return new Response(null, { status: 204, headers: { "x-user-id": user.id } });
}

List the session cookie in protectedCookies and the identity headers in authResponseHeaders. This is the right pattern when the login and the protected app share a cookie: the provider’s session cookie is scoped to a parent domain of the protected host, or login is hosted there. It is the shortest path to a working gate.

Pattern 3: Cross-Origin Handoff

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.

Your auth app lives on a different origin than the protected VM: accounts.example.com gating preview.example.com, or one sign-in serving many generated preview hostnames. A cookie set on the auth origin cannot be read on the preview origin, so Pattern 2 does not apply. Run a short handoff and set the session cookie on the protected domain at the end:

  1. No session: the authorizer 302s the visitor to your auth origin, passing the target host and the path they were trying to reach.
  2. Your auth origin signs the visitor in, mints a short-lived, single-use code bound to that host, and redirects to a callback on the protected domain, like /_auth/callback?code=….
  3. That callback is a request to the protected domain, so it hits the same forward-auth endpoint. The authorizer redeems the code and responds 302 with a Set-Cookie for a session scoped to the protected domain.
  4. Later requests carry that cookie; the authorizer validates it as in Pattern 2 and returns 204.

More machinery, but the only pattern that works when the provider and the protected surface cannot share a cookie, and it scales to a fleet of hostnames behind one sign-in. The Hexclave guide implements it end to end.

Which Pattern

If…UseLogin runs on
The caller is a program you can hand a secretMachine tokennothing
A person, and the session cookie is readable on the protected domainSession cookiethe provider
A person, and auth lives on a different originCross-origin handoffyour auth app

No human means the machine token. With a human, the only question is whether one cookie can be read on both the login page and the protected page: if it can, Pattern 2; if not, Pattern 3.

Sharp Edge: Identify By The Rule, Not The Host

Your authorizer usually needs to know which protected domain a request is for, to pick the policy or rebuild the return URL. Read that from X-Freestyle-TLS-Rule-Id, the id of the matched rule, which maps one-to-one to the rule you created.

Do not read it from X-Forwarded-Host. If your authorizer sits behind a reverse proxy (Vercel, Cloudflare, an API gateway, nginx), that proxy commonly overwrites the X-Forwarded-* headers, X-Forwarded-Host included, with its own values before your handler runs. Every protected domain then collapses to the same value, and a policy keyed on it matches the wrong thing or nothing. X-Freestyle-TLS-Rule-Id is not a standard forwarding header, so proxies pass it through untouched. Keep a map from rule id to { hostname, policy } where you create each rule, and look it up on every check. See the reference.

esc