cowl logo

Package VersionHex DocsLicense: MITCIBuilt with Gleam

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:


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

⚠️ Custom receives the raw value. Use tap_masked for 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