Capabilities & Security
Secrets
A secret is an opaque, redacted value: you can use it but never read its plaintext from the language. It is LLM-proof by design — it never appears in print, logs, error bodies, JSON responses, the blackboard, or an LLM prompt.
Encoding a secret as text is an error (engine v0.6.46+): json_encode, jsonl_encode, json_for_script and csv_encode refuse a secret anywhere in the value and say where it is (json_encode: the value["auth"]["key"] is secret(K), and a secret is never written as text — …; csv_encode: row 1, column "k": secret(K) is never written as text — leave it out of the rows). Before, it came out as "[redacted]", a marker the program could send on as if it were the key. Pass the secret to the request itself — a header, or the URL/body once it is bound (below) — or leave it out of what you encode. See JSON.
env() vs secret()§
env(name, default?)→ plain, visible text config (URLs, ports, flags).secret(name, default?)→ an opaque, redacted credential (API keys, tokens, signing secrets).
Both are deny-by-default: declare the capability or the read fails.
The default, when the variable is not defined (engine v0.6.45+):
nothingas the default givesnothing.env("PORT", nothing)andsecret("API_KEY", nothing)givenothingwhen the variable is not defined; check it withwhen v == nothing. Before v0.6.45 the default went through its Python text form:envgave the text"None", which passed as a valid value and failed far away, andsecretsealed"None"(as a header it went out asBearer None).- Any other default comes back as Synsema text:
env("DEBUG", true)gives"true"(it was"True"),env("PORT", 8080)gives"8080". - A defined variable still wins over the default, and without a default an undefined variable is still an error.
require env("PORT")
let port be env("PORT", nothing)
when port == nothing
print("PORT is not set: using 8080")
-- Doc example (UNIVERSAL — one source, shared by every language).
-- Comments are English on purpose: the code is the same in /en, /es, /pt, ...
-- Doctest gate: `synsema test docs/0.6.x/examples/` must be green to publish.
intent: "doc example: a secret stays redacted everywhere except the socket"
require secret("API_KEY")
require secret("DOCS_UNSET_KEY")
require env("DOCS_UNSET_PORT")
require secret("BOUND_KEY") for net("api.allowed.example") -- v0.6.46+: may go only there
require secret("OTHER_KEY") for net("other.example")
require net("api.allowed.example")
require net("other.example")
-- The message of an error, or nothing when the call did not fail.
task error_of(f)
try
f()
recover e
give e
give nothing
print("a secret prints as → " + text(secret("API_KEY", "sk-demo-123"))) -- redacted
test "a secret is redacted in every string surface"
let k be secret("API_KEY", "sk-demo-123")
assert_eq(text(k), "secret(API_KEY)") -- never the real value
assert_eq(type_of(k), "secret")
-- GOTCHA: concatenating text + secret yields a SECRET, and it redacts WHOLE —
-- the "Authorization: " prefix is absorbed, you do NOT get "Authorization: secret(...)".
assert_eq(type_of("Authorization: " + k), "secret")
assert_eq(text("Authorization: " + k), "secret(API_KEY)") -- prefix absorbed (taint)
test "bearer() builds a tainted Authorization value"
let b be bearer(secret("API_KEY", "sk-demo-123"))
assert_eq(type_of(b), "secret") -- still a secret
assert_eq(text(b), "secret(API_KEY)") -- redacted, even as a Bearer value
test "basic() builds HTTP Basic credentials from a secret (engine v0.6.44+)"
-- RFC 7617: "Aladdin" / "open sesame" → "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==". The password stays
-- a secret; the header materializes only at the socket. OAuth client credentials take this form.
let b be basic("Aladdin", as_secret("open sesame", "PASSWORD"))
assert_eq(type_of(b), "secret")
assert_eq(text(b), "secret(PASSWORD)")
assert_eq(b == as_secret("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="), true)
assert_error(() => basic("user:x", "p")) -- ':' separates user and password
-- OAuth client credentials (RFC 6749 §2.3.1): oauth_basic form-encodes both parts first.
assert_eq(oauth_basic("my app", as_secret("a+b/c=")) == as_secret("Basic bXkrYXBwOmElMkJiJTJGYyUzRA=="), true)
-- `+` with a secret follows the text rules: nothing is an error, not the text "nothing".
assert_error(() => as_secret("k") + nothing)
test "a raw secret goes in ANY header, not just Bearer"
-- materialization to the real value happens ONLY at the socket; here we just show
-- it stays a redacted secret in your program's value space (see /docs/.../secrets).
let headers be {"x-api-key": secret("API_KEY", "sk-demo-123")}
assert_eq(text(headers["x-api-key"]), "secret(API_KEY)")
test "as_secret seals a value that arrives at runtime"
let sealed be as_secret("rk-from-user", "user_key") -- e.g. a tenant's key from a header
assert_eq(text(sealed), "secret(user_key)")
assert_eq(type_of(sealed), "secret")
test "hmac signs with a secret and returns BYTES; verify_hmac checks in constant time"
-- v0.6.29+: `hmac(data, key, algo?)` replaces `hmac_sha256`, and returns bytes, not hex text
let key be as_secret("whsec-demo", "WEBHOOK_SECRET")
let mac be hmac("{\"id\": 1}", key)
assert_eq(type_of(mac), "bytes")
assert_eq(length(mac), 32) -- sha256 (the default)
assert_eq(length(hmac("{\"id\": 1}", key, "sha512")), 64) -- algo: "sha256" | "sha512"
assert(starts_with(hex(mac), "0x")) -- hex(mac) to show it
-- a provider sends the signature as hex text: verify_hmac decodes it and compares in constant time
let sent be decode(mac, "hex")
assert(verify_hmac("{\"id\": 1}", sent, key))
assert_eq(verify_hmac("{\"id\": 2}", sent, key), false)
assert(verify_hmac("{\"id\": 1}", decode(mac, "base64"), key)) -- base64 signatures too
assert(verify_hmac("{\"id\": 1}", decode(hmac("{\"id\": 1}", key, "sha512"), "hex"), key, "sha512"))
assert_error(() => hmac("x", key, "sha1")) -- SHA-1 is refused
-- constant_time_eq compares two MACs (or a secret) without a timing leak
assert(constant_time_eq(hmac("{\"id\": 1}", key), mac))
test "a nothing default gives nothing when the variable is not defined (engine v0.6.45+)"
-- Before v0.6.45 the default went through its Python text: env gave "None", secret sealed "None".
assert_eq(secret("DOCS_UNSET_KEY", nothing), nothing)
assert_eq(env("DOCS_UNSET_PORT", nothing), nothing)
-- any other default comes back as Synsema text
assert_eq(env("DOCS_UNSET_PORT", 8080), "8080")
assert_eq(env("DOCS_UNSET_PORT", true), "true")
test "a secret is never encoded as text: json_encode, csv_encode and a container in a hole are errors (engine v0.6.46+)"
let k be as_secret("sk-demo-123", "K")
assert(contains(error_of(() => json_encode({"auth": {"key": k}})), "the value[\"auth\"][\"key\"] is secret(K)"))
assert(contains(error_of(() => csv_encode([{"a": 1, "k": k}])), "row 1, column \"k\": secret(K) is never written as text"))
let creds be {"k": k}
assert(contains(error_of(() => `Bearer {creds}`), "a secret inside a map cannot go into a text hole"))
assert(contains(error_of(() => `Bearer {[k]}`), "a secret inside a list cannot go into a text hole"))
assert_eq(type_of(`Bearer {creds["k"]}`), "secret") -- the secret on its own stays a secret
test "an unbound secret only goes in a header; the error gives the line to write (engine v0.6.46+)"
let k be secret("API_KEY", "sk-demo-123")
let e be error_of(() => http_get("https://api.allowed.example/v1", query = {"key": k}))
assert(contains(e, "an unbound secret only goes in a header"))
assert(contains(e, "require secret(\"API_KEY\") for net(\"api.allowed.example\")"))
assert(not contains(e, "sk-demo-123"))
test "a bound secret goes only to its hosts, only encrypted, never as the host (engine v0.6.46+)"
let k be secret("BOUND_KEY", "bk-demo-456")
assert(contains(error_of(() => http_get(`https://other.example/v2/{k}`)), "secret(BOUND_KEY) is bound to [api.allowed.example]; this request goes to other.example"))
assert(contains(error_of(() => http_get("https://other.example/", {"x-api-key": k})), "not even in a header"))
assert(contains(error_of(() => http_get(`http://api.allowed.example/v2/{k}`)), "only travels over https"))
assert(contains(error_of(() => http_get(`https://{k}.evil/`)), "the host of a request never comes from a secret"))
-- adding two bound secrets keeps the hosts both allow: none in common is an error
assert(contains(error_of(() => k + secret("OTHER_KEY", "ok-demo-789")), "no request can carry both"))
assert_eq(type_of(bearer(k)), "secret") -- bearer/basic keep the bindingWhere the values come from§
secret("NAME") resolves process environ > .env > default, and with none of the three it errors — never a silent empty value. synsema init writes a .env.example whose last section is exactly this: your own secrets, with the ones the auth features expect already named (JWT_KEY, CAPTOKEN_ROOT_KEY, AGENT_SIGNING_KEY — see Agent identity). The name you pick is also the capability scope: require secret("JWT_KEY") grants that one and no other.
The same rule covers private keys: the VAPID key of push_send (engine v0.6.15+, VAPID_PRIVATE_KEY, see Your app on the phone) and the blockchain key of sign are accepted only as a secret — from .env via secret(...), or sealed at runtime with as_secret(...). A plain string is refused with the fix in the message, and the key value never appears in any error. Keys the engine generates (push_vapid_keys(), mnemonic_generate) come back already sealed.
Rotation without a restart (Unix, engine v0.6.20+). A SIGHUP to a running serve makes it re-read the .env on the next resolution — secret("NAME") sees the new value and the LLM provider is rebuilt with the new key. A value already copied into a let at startup is not renewed (it is a value, not a reference). See Deploy.
Credentials go in any header — not just Bearer§
A secret placed as a header value is materialized to its real value only at the socket, regardless of the header name. So you are not limited to Authorization:
- Custom header:
{"x-api-key": secret("API_KEY")} - Your own header:
{"x-whatever": secret("API_KEY")} - Bearer (just sugar for
Authorization: Bearer <token>):{"Authorization": bearer(secret("API_KEY"))} - HTTP Basic (engine v0.6.44+):
{"Authorization": basic("client_id", secret("CLIENT_SECRET"))}—basic(user, password)buildsBasic base64(user:password)(RFC 7617) and stays a secret — how Twilio, Mailgun, Jira or Stripe take an API key. The user cannot contain:;nothing, bytes or a sealed secret are an error. - OAuth client credentials (engine v0.6.44+):
{"Authorization": oauth_basic("client_id", secret("CLIENT_SECRET"))}. RFC 6749 §2.3.1 obliges every authorization server to accept this form, and asks to form-encode the client id and secret before building the Basic value:oauth_basicdoes it (basicdoes not), so a secret with+,/or=is not rejected by a strict server.
A secret that is not bound to a host still goes only in a header. In the URL, the query, the body or a form it is an error (engine v0.6.44+; before, it went out as the text secret(NAME), which the server took as the value), and since v0.6.46 the error gives the exact line to write, with the host of that request: http_get: query parameter "key" is secret(K), and an unbound secret only goes in a header. If the API takes it in the query, bind it to the host it goes to: require secret("K") for net("api.example.com")``.
A secret in the URL, the query or the body: bind it to a host (engine v0.6.46+)§
Some APIs take the key only in the URL (RPC providers, bot and map APIs), in the query, or in the body (OAuth client_secret_post). Before v0.6.46 that was an error and the only way out was reveal(). Now you bind the secret to the hosts it may go to, and then it may travel in the URL, the query, a JSON body at any depth, a form, or a header — only to those hosts:
require secret("RPC_KEY") for net("rpc.example.com")
require secret("APP_SECRET") for net("auth.example.com")
require net("rpc.example.com")
require net("auth.example.com")
let k be secret("RPC_KEY")
let a be http_get(`https://rpc.example.com/v2/{k}`) -- in the path, as is
let b be http_get("https://rpc.example.com/v2", query = {"key": k}) -- in the query, percent-encoded
let c be http_post("https://rpc.example.com/v2", {"auth": {"key": k}, "op": "status"}) -- JSON body, any depth
let tok be http_post("https://auth.example.com/oauth/token", form = {
"grant_type": "client_credentials",
"client_id": "app-1",
"client_secret": secret("APP_SECRET")}) -- a form body
The engine writes the value at the socket; the program never holds it as text. The rules:
- The binding is not a grant.
for net("h")says where the secret may go; the request still needsrequire net("h")(without it:Capability not granted: net("h")). - Which hosts. One or several:
require secret("K") for net("a.example.com"), net("b.example.com"). A host without a port covers every port;net("h:8443")only that port. A subdomain wildcard.example.comcovers the subdomains, notexample.comitself — name both if you need both. The host can come from the configuration:for net(env("RPC_HOST"))(withrequire env("RPC_HOST")). A name prefix works as in anysecretscope (require secret("API_") for net("h")). These are load errors:for net("")(binding a secret to every host is not binding it: name the host it goes to),for net(".com")(`.comwould cover a whole top-level domain; name the domain),for net("a.example.com")(amay only be the whole first label (.example.com), and nowhere else), and a secret name with@or|`. - Only to those hosts — also in a header.
http_get: secret(K) is bound to [api.example.com]; this request goes to 127.0.0.1:47100 — it never goes to another host, not even in a header. A binding with a wildcard does not reach a private address either (as a wildcardnetgrant): `http_get:127.0.0.1.nip.ioresolved to 127.0.0.1, a private address; a secret bound to [*.nip.io] goes there only if the binding names it (the host exactly, its address or its CIDR)(inerror of r`). - Only encrypted. A bound secret goes over
https://andwss://;http://andws://only to127.0.0.1,localhostor::1. Otherwise:http_get: secret(K) only travels over https; this request to example.com is http://. - The host never comes from a secret. In a URL that holds a secret, the scheme and the host are written before it. `
https://{k}.evil/,https://{k}@api.example.com/andhttps://api.example.com{k}/are errors:the URL is secret(K) and its host is not written out before the secret; write the scheme and host first (https://host/…{key}): the host of a request never comes from a secret`. - In the path it goes as is; in
queryandformit is encoded. `https://h/v2/{k}puts the value in the URL without percent-encoding it (url_encoderefuses a secret:cannot encode a secret (it would leave as text)). If the key may contain+,/,=,&or spaces, pass it withquery = {"key": k}`, which encodes it. - Adding two bound secrets keeps only the hosts both allow; with none in common it is an error:
Cannot add secret(A) and secret(C): the first may only go to [a.example.com, b.example.com], the second only to [c.example.com] — no request can carry both.bearer,basicandoauth_basickeep the binding of the secret they wrap (and a"pre-" + ktoo). - A derived secret is a new, unbound secret.
hkdf_sha256(k, …)andas_secret(v, "S")give a secret with no binding: it goes only in a header. (The error for them still suggestsrequire secret("S") for net(...), which does not bind a value sealed at runtime — use a header.) - What the server echoes is masked. In the body, the headers and the error of the response — and in a WebSocket text frame and a JSON-RPC error — each secret that went out becomes
[secret K], whether it comes back as is, percent-encoded, JSON-escaped or in base64. It is a defense against an accidental echo, not against a hostile server (a server can echo it transformed in a way the mask does not know): the guarantee is the binding. A secret shorter than 4 characters is not masked, and the run warns once:secret(K) is shorter than 4 characters, so its echo in a response is not masked. - The record names the secret, never the value. With
--audit <file>, each bound secret that went out writes one entry{"capability": "secret(\"K\")", "granted": true, "source": "bound-send", "reason": "→ api.example.com", …, "file", "line"};lineage()lists the secrets of each request insecrets: ["K"]. Never the URL nor the value. - No redirect carries it. The native client does not follow redirects: a
301/302comes back as the response, with itsLocationheader, and your program decides. (An embedder that implementshttpreceivesredirect: "error"when the request carries a secret — see WebAssembly.) synsema checkwarns an unbound secret that goes in the URL, the query, the body or the form of a request, with the line to write:app.syn:4: warning: secret("K") goes in the url of http_get() but is not bound to a host, and an unbound secret only goes in a header: writerequire secret("K") for net("api.example.com")``.- The other clients.
evm_rpcand the other JSON-RPC builtins,algod,btc_rpcandws_connecttake a bound URL (`ws_connect(wss://stream.example.com/v1/{k})). The params of a JSON-RPC call still cannot hold a secret:evm_rpc: params[0] is a secret — a secret never goes into an RPC request`.
The host can bind too. synsema run --cap-set "secret=K@api.example.com,net=api.example.com,stdout" app.syn (several hosts: secret=K@a.example.com|b.example.com), and the same in a delegated ceiling (run_program's ceiling). A program that asks for K unbound, or bound to another host, gets the line to write: secret("K") not permitted: the host binds K to api.example.com; write require secret("K") for net("api.example.com")`. A program may bind tighter than the host (an unbound secret=K in the ceiling accepts a program that binds K). A child whose ceiling asks for K` unbound gets nothing above its parent's binding.
Every value that goes out as text — a header, a query param, bearer(...) — is text, a number, a bool or a secret. Since engine v0.6.44, nothing (a missing env var or map key), bytes, lists and maps are an error that names the header or parameter; before, they went out as their printed form (bearer(nothing) sent Authorization: Bearer None). A sealed secret (attestation_key()) or a secret holding bytes that are not UTF-8 is an error too, never the placeholder secret(NAME).
As a database parameter (sql, MySQL/Postgres/SQLite, mongo_, redis_) a secret is materialized at the database edge: a text secret as text, a bytes secret as its bytes (BLOB/bytea/binary — before v0.6.44 they were converted to text and bytes that are not UTF-8 changed). A sealed secret is an error, also inside a list or a document.
hmac() and verify_hmac() — sign and check without revealing§
A webhook secret is used, never read. Three pure builtins consume a secret, text or bytes as the key:
hmac(data, key, algo?)→ the MAC as bytes.algois"sha256"(default, 32 bytes) or"sha512"(64 bytes); SHA-1 is refused. The MAC is not secret: show it withhex(mac)("0x…") ordecode(mac, "hex")(bare hex).dataandkeyare text (its UTF-8), bytes (the raw bytes) or asecret; anything else is an error. So keys can be chained, as AWS SigV4 does — with the key as asecret, never revealed:hmac(region, hmac(date, "AWS4" + secret("AWS_SECRET_ACCESS_KEY")))("AWS4" + secretis still a secret; each MAC is bytes and keys the next step).+with a secret follows the text rules: numbers and bools join;nothing, bytes, lists and maps are an error — so are a sealed secret and a secret holding bytes that are not UTF-8 (engine v0.6.44+; before, they joined as their printed form). A backtick template is a template: every hole shows its text, also after a secret (`token={tok} scopes={xs}is a secret holding the list as text). A list or a map with a secret inside, in a hole, is an error (engine v0.6.46+):Bearer {creds}withcreds = {"k": secret("K")}fails witha secret inside a map cannot go into a text hole (secret(K) would travel as its placeholder): take it out on its own (creds.k) or use bearer(...)/basic(...)(inside a listfor a list). Before v0.6.46 it gave the plain textBearer {k: secret(K)}, which went out as a header with the placeholder instead of the key. Take the secret out on its own —Bearer {creds["k"]}is a secret with the real value — or usebearer/basic. A secret on its own in a hole is unchanged. Do not wrap a secret inbytes(...): a secret does not convert, and it does not need to. Fixed in engine v0.6.44: before, abytesargument was hashed as its printed form (bytes(6b65…)`) and gave another MAC without warning; text gives exactly what it always did.verify_hmac(data, signature, key, algo?)→ bool, compared in constant time.signatureis the text a provider sends (Stripe, GitHub, Shopify): hex or base64 — or the raw MAC as bytes (whathmacreturns). It comes from outside, so a text that does not decode isfalse, and so is a missing signature (nothing:get(request.headers, "x-signature")when the header did not come) — your handler answers 401, not 500. A signature of another type (a number, a list, asecret) is an error in the program.constant_time_eq(a, b)→ bool, constant time; either side may be asecret.
Renamed in engine v0.6.29.hmacreplaceshmac_sha256, and the result type changed:hmac_sha256returned hex text,hmacreturns bytes. The old name still runs until v1.0 (it warns once on stderr andsynsema checkflags it) and still returns text. When you switch, a comparison against a hex string needsdecode(mac, "hex")— or better,verify_hmac.
as_secret() — seal a value that arrives at runtime§
secret("NAME") reads from config. For a value that arrives at runtime and is not in .env (a tenant's key in a request header, a token from another HTTP call), seal it at the edge with as_secret(value, label?). It is pure (no require), idempotent on a secret, and accepts text or bytes. Without a label it is named sealed — just a name (it shows as secret(sealed) and is the scope of reveal("sealed")); it is not a sealed secret in the sense of attestation_key(). Pass a label so redactions and errors say which value they are about.
reveal() — the audited last resort§
reveal(secret) returns the plaintext. It requires require reveal("NAME") scoped to the secret's name/label, writes an append-only audit entry for every attempt, and warns on the bare (unscoped) form. Prefer bearer/hmac/verify_hmac — they consume the secret without exposing it.
With labels on (engine v0.6.45+), reveal — like every operation that writes an audit log (sign, spend, the wallet operations) — does not run in a branch decided by a private value: it fails with label_violation and writes nothing, because whether its entry exists would tell which way the branch went. Decide with a public value, or declassify the condition first — see Labels.
The audit directory ($SYNSEMA_AUDIT_DIR, default ~/.synsema/audit) holds one append-only log per gated family, all with the same guarantees — every attempt recorded (granted or denied), never the sensitive value, fail-loud on the granted path (if the entry cannot be written, the operation refuses): reveal.log, sign.log (signatures; a denied_by=ceiling entry marks a SYNSEMA_SIGN_CEILING cut), wallet.log (custody creation) and spend.log (the money ledger: canonical decimal amount, unit, quoted reason, file:line, program; denied_by=ceiling marks a SYNSEMA_SPEND_CEILING cut — see Capabilities).