Freestyle Docs

Freestyle / Guides

How to Connect to Keycard with Credential Injection

Use Keycard to authorize API access from a Freestyle sandbox, with scoped tokens injected at the edge and kept out of the VM.

The real token stays outside the VM.

Keycard issues credentials for agents to access APIs and tools. Freestyle runs the agent’s code in a VM and can inject those credentials into outgoing HTTPS requests. Together, they let a sandbox use an authorized API without receiving its real access token.

This guide calls a read-only endpoint on your own Keycard-protected API. A trusted controller authenticates as a Keycard Application, requests a token for that API, and puts it in a Freestyle HTTP egress rule. The VM sends a placeholder; the edge replaces it with the token.

Freestyle’s routing rule does the proxying, so you do not need a separate proxy VM. Keycard is involved when the controller obtains a credential; the API request itself travels from the sandbox through the Freestyle edge to the API.

ComponentResponsibility
KeycardAuthenticate the Application, evaluate its access, and issue a resource-scoped credential.
Your controllerBind that access to a specific VM and manage the token’s lifetime.
FreestyleIsolate execution, restrict network access, and inject the token at the edge.
Your APIVerify the token’s issuer, audience, expiry, and required scopes; authorize the operation.

Use this pattern when sandbox code should have an API capability but should not be able to copy its credential. Code in the VM can still exercise the granted capability, so keep both the token’s permissions and the route narrow.

Choose The Routing Pattern

Start with edge credential injection when an agent in a sandbox needs to call an API. Use the other patterns when you need authentication on an incoming request or custom credential handling in the request path.

PatternRequest pathWhen to use it
Edge credential injectionSandbox VM → Freestyle edge → APIGive one VM scoped API access while keeping its token outside the guest. This is the walkthrough below.
Protect an API in a VMClient → Freestyle edge → API VMRun Keycard verification in your API, or have a trusted authorization endpoint verify the caller before the edge forwards the request.
A broker in a separate VMSandbox VM → trusted broker VM → APIRun your own code to select credentials, exchange tokens, or authorize individual operations as requests arrive.

For the first pattern, the destination: { public: true } rule reaches the API’s normal public origin and supplies the sandbox’s edge-access grant. If you want a different name to route to that API, TLS rules also support a destination.host alias. Host aliases currently require an existing allowed path to the edge, so use the direct-origin rule for this guide’s sandbox with restricted egress.

For an API hosted in a VM, publish its port with a public HTTP route and validate Keycard tokens in the API, as the Express example below does. To check access before requests reach the VM, implement a Keycard-aware authorization endpoint and attach it with forward auth. That endpoint verifies the caller’s token and permissions and returns an allow or deny response; Keycard’s token-issuance endpoint is not itself a forward-auth check. Forward auth currently supports public HTTP ingress to a VM port.

A broker VM is useful when your application must make credential decisions on each request. It must authenticate callers and enforce their permitted users, resources, and operations. Merely routing packets through a VM does not add Keycard authorization. Freestyle’s internal VM-to-VM TLS routes do not currently support credential transforms, so in this pattern your broker code obtains and attaches the downstream token. Keep that broker separate from untrusted sandbox code and keep its authenticated state out of reusable snapshots.

Set Up Keycard

Create a Keycard zone and copy its issuer URL from Settings → Connection. In the Keycard Console:

  1. Add a Resource for your API. Use its public HTTPS origin as the identifier, for example https://api.example.com, and choose the zone’s provider to issue Keycard tokens. Add a read scope.
  2. Add an Application for this agent workload, such as freestyle-report-reader. Add the API Resource under its Dependencies.
  3. Add a Client ID & Secret application credential. Store it only on your trusted controller. Ensure your zone’s access policies allow this Application to access the Resource.

This uses Keycard’s autonomous access flow: the Application acts as itself, without a signed-in user. The client secret remains a long-lived credential on the controller. If your controller’s hosting platform supports Keycard workload identity federation, you can use that instead. A Freestyle VM ID or Freestyle identity token is not a Keycard workload identity credential.

Keep the mapping from tenant, task, and VM to Keycard Application in trusted controller code. Separate Applications give separate agent identities; sharing one Application across VMs does not create a distinct Keycard identity for each VM.

Protect A Read-Only API

If you already have a Keycard-protected API, use its resource identifier, path, and required scope in the controller below. Otherwise, this small Express server provides the example endpoint. Install these packages on your API host:

npm install express @keycardai/express@0.9.1
npm install --save-dev tsx
export KEYCARD_ISSUER="https://your-zone.keycard.cloud"
export KEYCARD_RESOURCE="https://api.example.com"

Save as api.mts:

import express from "express";
import { requireBearerAuth } from "@keycardai/express";

const issuer = process.env.KEYCARD_ISSUER;
const resource = process.env.KEYCARD_RESOURCE;
if (!issuer || !resource) throw new Error("Set KEYCARD_ISSUER and KEYCARD_RESOURCE");

const app = express();
app.get("/healthz", (_req, res) => res.json({ ok: true }));
app.get(
  "/api/data",
  requireBearerAuth({
    zoneUrl: issuer,
    audience: resource,
    requiredScopes: ["read"],
  }),
  (_req, res) => res.json({ message: "Hello from a Keycard-protected API" }),
);
app.listen(8080);

Run npx tsx api.mts behind your API host’s HTTPS ingress on port 443. Replace https://api.example.com everywhere with that public origin. It must exactly match the Resource identifier registered in Keycard, including whether it has a trailing slash. The controller below uses an origin without a trailing slash. The API host needs outbound access to Keycard for token verification metadata.

The /healthz endpoint is public and contains no application data. A request to /api/data without a token must return 401; a token without read must be rejected too. Add your own record-level authorization before returning private data. See Keycard’s API guide for user delegation and OAuth discovery endpoints.

Prepare The Controller

Use Node.js 22 or newer on your trusted controller. Create a reusable Node.js snapshot with the Node.js sandbox guide, before adding any credentials. The guest uses built-in fetch and needs no Keycard package.

Install the controller dependencies:

npm install freestyle@latest @keycardai/oauth@0.25.0
npm install --save-dev tsx

The Keycard package versions in this guide are pinned because its TypeScript SDK is in preview. Set these values on the controller:

export FREESTYLE_API_KEY="your-freestyle-api-key"
export FREESTYLE_NODE_SNAPSHOT_ID="snapshot-id-from-the-node-guide"
export KEYCARD_ISSUER="https://your-zone.keycard.cloud"
export KEYCARD_CLIENT_ID="your-application-client-id"
export KEYCARD_CLIENT_SECRET="your-application-client-secret"
export KEYCARD_RESOURCE="https://api.example.com"

Request A Token And Run The Sandbox

Save the following as keycard-controller.mts. It requests read access, creates a VM with no general Internet firewall grant, and installs a route that injects the token only for GET /api/data on your API hostname.

import { ClientCredentialsClient } from "@keycardai/oauth";
import { Freestyle, type CreateTlsRuleOptions } from "freestyle";

function requiredEnv(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

const api = new URL(requiredEnv("KEYCARD_RESOURCE"));
if (
  api.protocol !== "https:" ||
  (api.port && api.port !== "443") ||
  api.username || api.password ||
  api.pathname !== "/" || api.search || api.hash
) {
  throw new Error("KEYCARD_RESOURCE must be an HTTPS origin on port 443");
}
const resource = api.origin;
const issuer = requiredEnv("KEYCARD_ISSUER");
const credentials = {
  clientId: requiredEnv("KEYCARD_CLIENT_ID"),
  clientSecret: requiredEnv("KEYCARD_CLIENT_SECRET"),
};
const keycard = new ClientCredentialsClient(issuer, credentials);

function apiRule(vmId: string, accessToken: string): CreateTlsRuleOptions {
  return {
    action: "allow",
    domain: api.hostname,
    source: { vmId },
    destination: { public: true },
    match: { method: ["GET"], path: { exact: "/api/data" } },
    transform: [{ headers: { authorization: `Bearer ${accessToken}` } }],
  };
}

const freestyle = new Freestyle();
const { vm, vmId } = await freestyle.vms.create({
  snapshotId: requiredEnv("FREESTYLE_NODE_SNAPSHOT_ID"),
  firewall: { rules: [] },
});

try {
  const requestedAt = Date.now();
  const token = await keycard.requestToken({ resource, scope: "read" });
  if (
    token.tokenType.toLowerCase() !== "bearer" ||
    !token.accessToken ||
    !Number.isFinite(token.expiresIn) ||
    (token.expiresIn ?? 0) <= 90
  ) {
    throw new Error("Expected a Bearer token with more than 90 seconds to live");
  }
  const expiresAt = requestedAt + token.expiresIn! * 1_000;
  const route = await freestyle.tls.rules.create(apiRule(vmId, token.accessToken));
  console.log({ vmId, ruleId: route.id });

  await vm.fs.writeTextFile("/opt/read-api.mjs", `
const response = await fetch(new URL("/api/data", process.env.API_ORIGIN), {
  headers: { authorization: "Bearer unused-placeholder" },
  redirect: "error",
  signal: AbortSignal.timeout(20_000),
});
if (!response.ok) throw new Error("API returned HTTP " + response.status);
console.log(await response.text());
`);

  const node = "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
  const guestEnv = {
    API_ORIGIN: resource,
    NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
  };

  // Wait for hostname steering and the Freestyle CA without calling /api/data.
  let ready = false;
  for (let attempt = 0; attempt < 10; attempt++) {
    const probe = await vm.exec({
      command: `${node} node --input-type=module -e '
        const r = await fetch(new URL("/healthz", process.env.API_ORIGIN), {
          redirect: "error", signal: AbortSignal.timeout(5_000)
        });
        if (!r.ok) process.exit(1);
      '`,
      env: guestEnv,
      timeoutMs: 10_000,
    });
    if (probe.statusCode === 0) {
      ready = true;
      break;
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  if (!ready) throw new Error("API route did not become ready");
  if (Date.now() + 30_000 >= expiresAt) {
    throw new Error("Token is too close to expiry; request a fresh token");
  }

  const result = await vm.exec({
    command: `${node} node /opt/read-api.mjs`,
    env: guestEnv,
    timeoutMs: 30_000,
  });
  if (result.statusCode !== 0) {
    throw new Error(result.stderr ?? "API request failed");
  }
  console.log(result.stdout);
} finally {
  // Deleting the VM also removes the TLS rule that names it.
  await vm.delete();
}

Run it with npx tsx keycard-controller.mts. The expected output includes the VM and rule IDs, followed by the API’s greeting. The example deletes its VM after the request, including when setup or execution fails.

Only the placeholder reaches the guest. The Keycard client secret stays on the controller; the access token goes to Freestyle’s edge configuration, where header values are sealed at rest and redacted on readback. Node trusts the Freestyle CA through NODE_EXTRA_CA_CERTS; certificate verification stays on.

Renew Access For Longer Tasks

Freestyle injects the configured header as a fixed value. It does not refresh OAuth tokens or contact Keycard for each request. For a long-running job, have the controller obtain a new token before the current one expires and replace the full rule:

const replacement = await keycard.requestToken({ resource, scope: "read" });
await freestyle.tls.rules.update(route.id, apiRule(vmId, replacement.accessToken));

In an extended version of the controller, retain route.id and the expiry deadline outside the one-shot job. Validate the replacement token as above, schedule renewal from its returned expiresIn, and allow time for the update to propagate. Cached rules can take about five seconds to refresh. Supply the actual token and all match conditions on every update; redacted rule reads cannot reconstruct the secret.

If renewal fails, stop dispatching work and remove the rule or delete the VM. Do not silently fall back to a broader credential. On resume after hibernation, check expiry and renew access before restarting API work.

Understand The Boundaries

  • A matcher selects credential injection. Other paths on this hostname can still reach the origin without the injected token. Query strings do not participate in path matching. The API must enforce authentication and authorization for every protected operation.
  • Identity belongs to the Application. This example has no human subject. Keep your own task-to-VM-to-rule mapping for attribution. Local JWT verification and repeated API calls do not imply a fresh Keycard policy decision or a Keycard audit event for each call; log requests at the API too.
  • Revocation has a lifetime boundary. Keycard documents that revoking a user grant prevents new credentials but does not invalidate existing ones. That grant flow applies to delegated users, not the autonomous Application above. For autonomous access, remove the Application’s authorization and stop renewal; also remove its Freestyle route when the task ends. Rule changes do not undo requests already in flight.
  • Keep reusable snapshots free of credentials. This example never writes real tokens into the VM. If you instead run the Keycard CLI or SDK in a guest, credentials in its filesystem or memory can be captured by a snapshot. Bind new VMs to the intended identity and route explicitly.

Access APIs On Behalf Of A User

For a sandbox working with a user’s GitHub, Google, or Slack account, use delegated token exchange on the controller. Configure the provider and Resource in Keycard, add the Application’s dependency, and complete the user’s authorization first. Client credentials alone cannot produce a user’s brokered OAuth token.

The Keycard SDK’s TokenExchangeClient.exchangeToken() accepts the user’s Keycard subject token and the target Resource. Inject the returned API token through a route for that provider’s exact hostname and operation. Keep the subject token and any refresh credentials on the controller, and bind the VM to the authenticated user in server-side state. Never accept an arbitrary user identifier or Resource URL from sandbox code to choose whose access it gets.

For MCP tool access, Keycard also offers a Unified Access Gateway and MCP SDKs. Its CLI supports Linux and device sign-in for remote machines. Those are useful alternatives when you want Keycard sessions inside the guest; they have different credential-storage and snapshot boundaries from this edge-injection example.

esc