cowl
Type-safe secret masking for Gleam. Wrap passwords, API keys, and other
sensitive values in Secret(a) so they never appear in logs or debug output.
The name comes from Batman’s cowl, the mask that hides his true identity. What if there was a cow under that cowl? Who knows?
Install
gleam add cowl
Quick start
import cowl
import cowl/unsafe // explicit danger zone — see below
// Wrap at the boundary.
let key = cowl.labeled("sk-abc123xyz789", "openai_key")
let tok = cowl.token("sk-abc123xyz789") // smart peek masker built in
// Safe display — never the raw value.
cowl.mask(key) // "***"
cowl.mask(tok) // "sk-a...y789"
cowl.mask_with(key, cowl.Peek(cowl.Last(4), "...")) // "...y789"
cowl.field(key) // #("openai_key", "***")
// Use the value inside a callback — it cannot escape.
cowl.with_secret(key, fn(raw) { send_request(raw) })
// Safe side effects — callback receives the masked string, not the raw value.
cowl.tap_masked(tok, fn(m) { logger.info("key: " <> m) })
// Raw extraction lives in cowl/unsafe — visible in every code review.
unsafe.reveal(key)
The boundary principle
Cowl splits operations into two zones:
cowl— everything safe. No raw value ever leaves a callback.cowl/unsafe— touches the raw value directly. Animport cowl/unsafein production code is a code-review red flag by design.
Constructors
| Constructor | Masker default | When to use |
|---|---|---|
secret(v) | "***" | Generic value, any type |
string(v) | "***" | Explicit string variant of secret |
token(v) | Both(4,4) peek | API keys and tokens |
new(v, masker) | Custom function | Any type with explicit masking |
labeled(v, label) | "***" | Named secret for structured logging |
Masking strategies
mask_with accepts a Strategy and always operates on Secret(String).
mask uses the secret’s built-in masker (set at construction) or "***".
cowl.mask_with(s, cowl.Stars) // "***"
cowl.mask_with(s, cowl.Fixed("[redacted]")) // "[redacted]"
cowl.mask_with(s, cowl.Label) // "[openai_key]"
cowl.mask_with(s, cowl.Peek(cowl.Both(3, 4), "...")) // "sk-...y789"
cowl.mask_with(s, cowl.Custom(string.uppercase)) // raw → transformed
⚠️
Customreceives the raw value. Usetap_maskedfor logging instead.
Transformation
// map — transforms the value, preserves label, drops masker (type changed).
cowl.secret("hunter2") |> cowl.map(string.length) // Secret(Int)
// and_then — like map but for functions that return Secret. Inner masker carried forward.
cowl.secret("hunter2") |> cowl.and_then(fn(pw) { hash(pw) |> cowl.secret })
// map_label — rename label without touching value or masker.
cowl.labeled("tok", "old") |> cowl.map_label(string.uppercase)
Loading from fallible sources
import cowl
import envoy
envoy.get("OPENAI_API_KEY") |> cowl.labeled_from_result("openai_key")
// Result(Secret(String), envoy.NotFound)
dict.get(cfg, "db_pass") |> cowl.labeled_from_option("db_pass")
// Option(Secret(String))
Structured logging
field and field_with return #(String, String) tuples for any
key-value logging API, including woof.
woof.info("request", [
cowl.field(api_key), // #("openai_key", "***")
cowl.field_with(api_key, cowl.Peek(cowl.Last(4), "...")), // #("openai_key", "...y789")
])
Writing an adapter
Adapters must never import cowl/unsafe. Use with_secret only:
pub fn bearer_auth(req: Request, token: Secret(String)) -> Request {
cowl.with_secret(token, fn(raw) {
req |> request.set_header("authorization", "Bearer " <> raw)
})
}
Note on io.debug
The value lives inside a closure — io.debug, echo, and string.inspect
print the closure reference, not the raw value. Use cowl.to_string for
safe debug output:
io.debug(cowl.to_string(password)) // "Secret(***)"
Migrating from 1.x
See MIGRATION.md.
Made with 💜 in Gleam. MIT — cowl · lupodevelop · 2026