Secret Fields
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_hashwithruntime.HashPassword(argon2id) — see/auth/loginreference.LLMCredential.encrypted_keyfor BYO-key AI agent setups (Loom Core's pattern).- OAuth refresh tokens stored on a
ConnectionThread. - 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: truecontrols 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'sLLMCredential.encrypted_keyis 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 aboutMarshalJSON. 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 respectsMarshalJSON. Most third-party JSON libraries do too, but verify.- Error messages can leak.
validatetags 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/loginreadsUser.password_hashto verify