---
slug: 21-secrets
title: Secrets
description: Use API keys, tokens and webhook secrets in Synsema without ever exposing them — secret, as_secret, bearer, hmac and the scoped reveal.
example_ids: [secret-bearer]
---

# 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.

## `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.

```synsema
-- 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.5.x/examples/` must be green to publish.
intent: "doc example: a secret stays redacted everywhere except the socket"
require secret("API_KEY")

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 "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")
```

## Where 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](/en/0.6.x/46-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](/en/0.6.x/41b-pwa)) 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.

## 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"))}`

In **query params and the body** a `secret` is **redacted** (fail-closed) — a credential
only survives over the wire inside a header.

## `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.

## `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_sha256`/`verify_hmac` — they consume the
secret without exposing it.

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](/en/0.6.x/20-capabilities)**).
