Intermediate May 7, 2026 · 3 min read

Some Thread fields hold sensitive values — password hashes, encrypted API keys, signing secrets, OAuth tokens. These should be writable by clients (during create/update) but never returned in API responses. Loom expresses this with a single field-level flag.

The flag

Mark the field secret: true:

model: User
fields:
  - name: email
    type: email
    required: true
    unique: true
  - name: password_hash
    type: text
    required: true
    secret: true
    help_text: Argon2id PHC string. Never returned in API responses.

The validator allows secret: true only on string-like field types: text, textarea, markdown, code. Boolean / link / uuid / numeric fields can't be secret — there's nothing meaningful to hide on a non-string value, and the omission mechanism (see below) only works cleanly for string zero values.

What the generator does

Two changes to the generated Go model:

1. JSON tag gets ,omitempty — so when the field's value is empty in the marshalled struct, encoding/json drops it from the output:

PasswordHash string `db:"password_hash" json:"password_hash,omitempty" validate:"required"`

2. A MarshalJSON method that copies the struct, zeroes every secret field, then standard-marshals:

func (r User) MarshalJSON() ([]byte, error) {
    type alias User
    redacted := alias(r)
    redacted.PasswordHash = ""
    return json.Marshal(redacted)
}

The combination of omitempty on the tag and the zero assignment in MarshalJSON means the field is structurally absent from API responses — not "present but empty", not "redacted to a placeholder", but the JSON object simply has no password_hash key at all.

Input still works

UnmarshalJSON is NOT overridden. So POST /api/users with:

{
  "email": "alice@example.com",
  "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$..."
}

works as expected — the field gets populated. Only OUTPUT is redacted.

This asymmetry is intentional. Apps that need to write the field (signup endpoints, password resets, key rotation) can do so through the same generic CRUD handlers Loom generates. There's no separate "secret-write" endpoint, no extra plumbing.

Common pairings

  • User.password_hash with runtime.HashPassword (argon2id) — see /auth/login reference.
  • LLMCredential.encrypted_key for BYO-key AI agent setups (Loom Core's pattern).
  • OAuth refresh tokens stored on a Connection Thread.
  • Webhook signing secrets for inbound integrations.

For multi-line secrets (PEM keys, JWT secrets with line breaks) use type: textarea instead of type: text — same secret: true flag applies.

Caveats

  • DB-level encryption is your responsibility. secret: true controls the API surface, not at-rest storage. The runtime stores whatever bytes you write — apply encryption (AES-GCM, libsodium, your KMS of choice) before insert and decrypt before use. Loom Core's LLMCredential.encrypted_key is so named to make that contract explicit.
  • Logs leak. slog.Info("created user", "user", record) will run the User through the standard logger which doesn't know about MarshalJSON. Don't pass a struct with secrets to log lines. Pass scalar fields by name ("user_id", record.ID) or use a sanitiser before logging.
  • render.JSON(w, r, record) is safe. Standard library marshaller respects MarshalJSON. Most third-party JSON libraries do too, but verify.
  • Error messages can leak. validate tags fire BEFORE marshal, so a 400 with {"error":"validation failed: password_hash required"} could surface the field name (just the name, not the value). That's typically fine; if it isn't for you, customise the error renderer.

See also

  • Concepts → Multi-tenancy — secret fields and tenant scoping work independently and frequently together
  • Reference → Auth Endpoints/auth/login reads User.password_hash to verify
Was this article helpful?