Skip to content

Secrets and outbound calls

A secret is a credential for a third-party API, such as a Stripe key, that we store for your app. Your code calls the API without the key. Our egress proxy adds it as a header to requests for the host you name, so the key stays out of your app, its logs and its versions.

Terminal window
forculus secret set acme/hello STRIPE_KEY --host api.stripe.com --header Authorization --format "Bearer {value}"
Paste the value and press Enter. It stays hidden:
Read 1 line, 107 characters.
Stored STRIPE_KEY. We'll send it as Authorization on requests to api.stripe.com.

At a terminal, you paste the value at a prompt that doesn’t echo it, and it says how much it read, so you can tell a paste that came up short.

When the value was typed rather than pasted, runs over more than one line, or comes from a Windows terminal, which sends a paste as typing, it asks before storing it:

Paste the value and press Enter. It stays hidden:
Read 1 line, 107 characters.
If that's less than you pasted, it got cut short. Answer n and pipe the value in instead, which reads all of it: forculus secret set ... < value.txt
Store it? [y/N]

Answer y to store it. The default is no, so pressing Enter alone stores nothing.

Otherwise the value is read from stdin, without a trailing newline, which works in CI:

Terminal window
printf '%s' "$STRIPE_KEY" | forculus secret set acme/hello STRIPE_KEY --host api.stripe.com --header Authorization --format "Bearer {value}"

--value takes it on the command line instead, but then it ends up in your shell history.

Option What it does
--host The one host that gets the header, matched exactly: api.stripe.com is not stripe.com and not files.stripe.com.
--header The header that carries it, such as Authorization or X-Api-Key.
--format How the header’s value is built, with {value} exactly once. The default is the value on its own.

Setting a secret that already exists replaces its value, host, header and format. You can do the same in the app’s Secrets tab in the dashboard.

Leave the header out of your request. We add it:

const res = await fetch("https://api.stripe.com/v1/customers", {
method: "POST",
body: new URLSearchParams({ email: "someone@example.com" }),
});

This works the same in a Worker and in a container. A container’s HTTPS requests also go through the proxy, and the variables it needs to trust the proxy are set for you.

Every outbound request from your app goes through the egress proxy. For each request it:

  • adds the secrets for that request’s host, each in its header, replacing a header of the same name your code set,
  • adds them only over https, so a request to http://api.stripe.com goes out without them,
  • doesn’t let a secret follow a redirect to another host,
  • refuses requests to our own hosts, with a 403 and Refused: apps can't send requests to Forculus itself.,
  • adds the header X-Forculus-Outbound: 1, which the API you call sees too, and which tells a custom domain on Forculus to refuse the request,
  • refuses every request, over http or https, while the app isn’t being served because its workspace is suspended or over a limit that stops it. The answer is a 403 with Refused: this app is stopped, so it can't send requests.

Several secrets can go to one host, each in its own header.

The proxy keeps what it looked up for 10 seconds, so a change to a secret, or the app stopping or starting again, reaches your app’s requests within that time.

Secret values are encrypted at rest. Once a value is set, nothing in the CLI, API or dashboard shows it again.

We keep the value out of your app, but the host you named receives it on every request, and your code reads that host’s responses. If the host echoes request headers, as a debugging endpoint does, or quotes the key in an error message, your code can read the key from the response. Anyone who can deploy the app can write code that calls such an endpoint.

Name only a host you trust with the key. Assume everyone who can deploy the app can use the key, and, through a host like that, read it. If you think a key has been read, rotate it with the provider and set the new value.

Terminal window
forculus secret list acme/hello
SENDGRID_KEY Authorization → api.sendgrid.com
STRIPE_KEY Authorization → api.stripe.com

The list shows each secret’s name, header and host, without its value.

Terminal window
forculus secret remove acme/hello SENDGRID_KEY
Removed SENDGRID_KEY.

Within 10 seconds, requests to that host go out without the header.

Setting, listing and removing secrets takes an editor or owner of the app, or a workspace admin or owner. Each change is recorded in the audit log with the secret’s name and host, without its value.

Name Uppercase letters, digits and underscores, starting with a letter, at most 64 characters.
Value 1 to 8,192 bytes, with no control characters.
Host A hostname with at least one dot, and not one of ours or a name under .internal, .local or .localhost, which the proxy never sends to. An IPv4 address is fine if it’s public, or on your private network, like 10.0.4.12. Loopback, link-local and other reserved addresses, like 127.0.0.1 or 169.254.169.254, are refused, since the proxy never sends to them either. So is an address written any other way than four numbers, like 127.1 or 0x7f.0.0.1, since a request never names it that way. It’s stored in lowercase.
Header Letters, digits and dashes, at most 64 characters. Not Host, Content-Length, Transfer-Encoding, Connection, Upgrade, Cookie, TE, Trailer, Keep-Alive, Proxy-Authorization, or any X-Forculus-* or CF-* header.
Format At most 128 characters, with {value} exactly once, and no tab, line break or other control character.

forculus secret set checks the name and the flags before it asks for the value, so you don’t paste a value only to have it refused. The errors for each:

error: Secret names are uppercase letters, digits and underscores, like STRIPE_KEY.
error: Secret values must be 1 to 8,192 bytes, with no control characters.
error: --host takes a hostname like api.stripe.com, with no scheme, port or path: --host api.stripe.com.
error: --host localhost has to be a public name with a dot in it, like api.stripe.com. A name like localhost only means something inside one network, and we send secrets over the internet.
error: --host 127.0.0.1 is never reached from an app: loopback, link-local and other reserved addresses are refused. Name the host of the service it's for, or an address on your private network.
error: --header X Token isn't a header name: letters, digits and dashes, like Authorization.
error: --header Host can't carry a secret: HTTP or the platform sets it.
error: --format must contain {value} exactly once, like "Bearer {value}".
error: --format is at most 128 characters.
error: --format can't have a tab, a line break or any other control character.

When --host is a URL, or has a port or a path, the error ends with its hostname, to use instead. With --json, the errors for the name and the flags have the code invalid_argument. The dashboard, the API and the agent tools say the same things in their own words.

Environment variables are plain text that your code reads and anyone who can deploy the app can list. For that reason, forculus env set refuses values that are clearly credentials and points you here.

  • Database for the data your app stores.
  • Security for how we keep credentials out of your code.