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:
2xxallows. AnyauthResponseHeadersyou set replace the caller’s values on the request that reaches the VM.3xx/4xxis returned to the browser verbatim. Freestyle does not follow redirects, so a302sends 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
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.
Pattern 2: Session 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
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:
- No session: the authorizer
302s the visitor to your auth origin, passing the target host and the path they were trying to reach. - 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=…. - That callback is a request to the protected domain, so it hits the same
forward-auth endpoint. The authorizer redeems the code and responds
302with aSet-Cookiefor a session scoped to the protected domain. - 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… | Use | Login runs on |
|---|---|---|
| The caller is a program you can hand a secret | Machine token | nothing |
| A person, and the session cookie is readable on the protected domain | Session cookie | the provider |
| A person, and auth lives on a different origin | Cross-origin handoff | your 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.