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?
gleam add cowlimport 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)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.
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.
// 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)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))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")
])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)
})
}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(***)"See MIGRATION.md.
Made with 💜 in Gleam. MIT - cowl · lupodevelop · 2026

