The Wasm Component Model on Fastly Compute
Secret Store: Configuration You Don't Want in a Log Line
August 13, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): added a comparison against the real Rust SDK's SecretStore, which keeps the two-call retrieval intact.
Last time was Config Store: a way to hand your Compute app configuration values that Fastly's own tooling can update without a redeploy. Some of what you'd configure an app with is fine to see in a log line. Some of it, an API key, a signing key, isn't. That's what Secret Store is for.
It looks similar to Config Store from a distance: open a store, look up a key by name. You might assume get works the same way here too, handing you back a string.
The whole interface
/// [Secret Store] API.
///
/// [Secret Store]: https://www.fastly.com/documentation/reference/api/services/resources/secret-store/ (opens in a new window)
interface secret-store {
use types.{error, open-error};
/// An individual secret.
resource secret {
/// Creates a new “secret” from the given memory.
///
/// This is *not* the suggested way to create `secret`s; instead, we suggest using `get`.
/// This secret will *NOT* be shared with other sandboxes.
///
/// This method can be used for data that should be secret, but is being obtained by
/// some other means than the secret store. New “secrets” created this way use plaintext
/// only, and live in the sandbox's memory unencrypted for much longer than secrets
/// generated by `get`. They should thus only be used in situations in which an API requires
/// a `secret`, but you cannot (for whatever reason) use a `store` to store them.
///
/// As the early note says, this `secret` will be local to the current sandbox, and
/// will not be shared with other instances of this service.
from-bytes: static func(bytes: list<u8>) -> result<secret, error>;
/// Returns the plaintext value of this secret.
plaintext: func(
max-len: u64
) -> result<list<u8>, error>;
}
/// A Secret Store.
resource store {
/// Opens the Secret Store with the given name.
open: static func(name: string) -> result<store, open-error>;
/// Tries to look up a Secret by name in this secret store.
///
/// If successful, this method returns `ok(some(s))` containing the found secret `s` if the
/// secret is found, or `ok(none)` if the secret was not found.
get: func(
key: string,
) -> result<option<secret>, error>;
}
}
store.get returns result<option<secret>, error>. secret is a resource—a handle to something the host is still holding, not a value sitting in your hands. Getting the actual bytes takes a second call, on that handle:
/// Returns the plaintext value of this secret.
plaintext: func(
max-len: u64
) -> result<list<u8>, error>;
Two calls where Config Store needed one: store.get(key) gets you a secret; secret.plaintext(max-len) gets you the bytes. Notice the return type on plaintext, too—list<u8>, not string. Config Store values are guaranteed text; a secret might be a certificate, a signing key, anything binary.
Carefully reading these signatures shows you that the ABI isn't willing to promise UTF-8 here any more than it was for header values.
Why the indirection?
Wrapping the value in a resource makes "holding a secret" and "having looked at its plaintext" two distinct actions at the ABI level. With Config Store, getting the value is getting the value—there's no in-between state. With Secret Store, you can hold a secret handle for a while, pass it around, decide whether you actually need the plaintext at all, and only pay for that last step—the actual decryption and byte copy—at the moment you call plaintext.
The secret-store interface's own doc comments don't spell out why that matters, but a consumer elsewhere in compute.wit does. dynamic-backend-options has a client-cert setter that takes a secret directly, and its doc comment says exactly what that buys you:
/// Provides the given client certificate to the server as part of the TLS handshake.
///
/// Setting this will enable TLS for the connection as a side effect. Both the certificate and
/// the key to use should be in standard PEM format; providing the information in another
/// format will lead to an error. We suggest that (at least the) key should be held in
/// something like the Fastly secret store for security, with the handle passed to this
/// function without unpacking it via `secret.plaintext`; the certificate can be held in a less
/// secure medium.
///
/// (If it is absolutely necessary to get the key from another source, we suggest the use of
/// `secret.from-bytes`.
client-cert: func(client-cert: string, key: borrow<secret>);
That's the design paying off, in writing: client-cert takes a borrow<secret>, not a list<u8>, specifically so your code can hand a key to the TLS handshake without ever calling plaintext on it. Because all you're holding is a handle, not the bytes themselves, the actual secret value never has to enter your guest code at all: it goes from Secret Store, through the host, straight into dynamic backend creation, and your own code never sees it.
Fastly's own dynamic config guide (opens in a new window) names the concrete difference from Config Store outright: "the data stored in Secret Store is encrypted and then automatically cached in all Fastly POPs." Config Store's values were never secret to begin with; Secret Store's are, and the two-call shape is what lets you treat one like that: hold the handle, pass it where it needs to go, and only touch the plaintext if you actually have to.
Fastly's Rust SDK keeps that shape, which is good evidence the indirection is load-bearing rather than an ABI artifact. SecretStore::get(name) (opens in a new window) returns Option<Secret>, not Option<String>. Secret stays a type of its own, reading it is still a separate call (plaintext(&self) -> Bytes), and Secret::from_bytes carries the same "this is not the suggested way" warning the WIT doc comment does. An SDK optimizing purely for convenience would have collapsed the two calls into one and handed you a string.
One inconsistency is worth noticing while you're over there. SecretStore::open returns Result<Self, OpenError>, where ConfigStore::open returns the store outright and defers its failure to read time. That's the same open-error underneath and two different answers about whether opening is something a caller should have to handle. The ABI being uniform is exactly what gives a binding the room to not be.
The escape hatch you're told not to use
secret also has a constructor that doesn't touch the store at all:
/// Creates a new “secret” from the given memory.
///
/// This is *not* the suggested way to create `secret`s; instead, we suggest using `get`.
/// This secret will *NOT* be shared with other sandboxes.
///
/// This method can be used for data that should be secret, but is being obtained by
/// some other means than the secret store. New “secrets” created this way use plaintext
/// only, and live in the sandbox's memory unencrypted for much longer than secrets
/// generated by `get`. They should thus only be used in situations in which an API requires
/// a `secret`, but you cannot (for whatever reason) use a `store` to store them.
///
/// As the early note says, this `secret` will be local to the current sandbox, and
/// will not be shared with other instances of this service.
from-bytes: static func(bytes: list<u8>) -> result<secret, error>;
Read that doc comment again, because it's more of a warning than a description: it's not the suggested way to create a secret; it's unshared across sandboxes; it lives in memory unencrypted, and for longer than one that came from get.
This exists for exactly one situation—some other API in your code requires a secret-typed value, but the actual bytes arrived some other way (an environment value, a request header, wherever) rather than through a Secret Store. It's an adapter, not an alternative: if you find yourself reaching for it because opening a real store felt like more setup than you wanted, that's the doc comment telling you it isn't worth the shortcut.
Reading a secret
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{http_body, http_req, http_resp, secret_store},
};
fn get_header_value(
request: &http_incoming::Request,
name: &str,
) -> Result<Option<String>, http_req::Error> {
let mut max_len: u64 = 128;
loop {
match request.get_header_value(name, max_len) {
Ok(Some(bytes)) => return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Ok(None) => return Ok(None),
Err(http_req::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
struct SecretStoreExample;
impl http_incoming::Guest for SecretStoreExample {
fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let key = get_header_value(&request, "x-key")
.map_err(|_| ())?
.unwrap_or_else(|| "api-token".to_string());
let store = secret_store::Store::open("creds").map_err(|_| ())?;
// get returns a `secret` handle, not the value itself.
let secret = store.get(&key).map_err(|_| ())?;
let msg = match secret {
// The plaintext bytes only come out via a second call.
Some(s) => {
let bytes = s.plaintext(1024).map_err(|_| ())?;
let value = String::from_utf8_lossy(&bytes).into_owned();
format!("\"{key}\" = \"{value}\"\n")
}
None => format!("\"{key}\" is not set in this Secret Store\n"),
};
let response = http_resp::Response::new().map_err(|_| ())?;
response
.insert_header("content-type", b"text/plain")
.map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
http_body::write(&out_body, msg.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(SecretStoreExample with_types_in bindings);
DON'T ACTUALLY DO THIS IN YOUR CODE
Writing a secret's plaintext straight into a response body, the way this example does, is fine for a local demo against a fake token—it's the whole point, proving the two-call retrieval actually works. It's a bad habit to carry into real code. Once you've called
plaintext, you're holding a plain byte string with no more protection than any other variable; logging it, echoing it back to a client, or writing it somewhere you wouldn't write a password undoes most of the reason to use a Secret Store in the first place. Callplaintextas late as possible, and only where the value is actually about to be used.
Running it
Full working code: full example on GitHub (opens in a new window). Local test data lives directly in fastly.toml:
[local_server.secret_stores]
[[local_server.secret_stores.creds]]
key = "api-token"
data = "s3cr3t-token-value"
fastly compute serve
curl http://127.0.0.1:7676/ (opens in a new window)
"api-token" = "s3cr3t-token-value"
curl -H "x-key: nope" http://127.0.0.1:7676/ (opens in a new window)
"nope" is not set in this Secret Store
Beyond the WIT
The from-bytes doc comment is doing more work than it looks like at first read: it's the ABI telling you, in the one place it can, that not every secret is equally trustworthy. A secret from get came from an encrypted store and (per that same doc comment) is shared consistently across every instance of your service; a secret from from-bytes is whatever bytes you handed it, unencrypted in memory, and scoped to this one sandbox only. Both are the same type, and both respond to plaintext the same way—but the doc comment is the only thing distinguishing "the store did the hard part" from "you're vouching for this yourself." Nothing in the type system forces that distinction on you at the call site; it's on you to remember which kind of secret you're actually holding.
Next: Config Store and Secret Store are both read-only from inside Compute—writing happens somewhere else entirely, outside the ABI. KV Store breaks that pattern. It's the data store you can actually write to from your own code, with real read-and-write machinery to match.