Masked fields (encrypt at rest)
Encrypt sensitive columns at rest with public-key cryptography. GDPR-style field encryption with Masked<T>.
Masked fields
Masked<String> is a string field that is stored encrypted in the database and redacted everywhere it would otherwise leak. Use it for PII (phone numbers, national IDs, addresses) and secrets (API keys, OAuth tokens): a stolen database dump leaks ciphertext, not plaintext.
The full surface lives in umbral::orm: Masked, MaskKeyring, MaskError, set_mask_keyring.
Masked is the building block the OAuth plugin uses to store provider access/refresh tokens. Anything you'd be uncomfortable seeing in a pg_dump is a candidate.
How it works
Encryption is public-key (X25519 + XSalsa20-Poly1305 sealed boxes, via the RustCrypto crypto_box crate, in the same anonymous-sender construction as libsodium's crypto_box_seal). A fresh ephemeral keypair is generated per value and sealed to the configured public key. The public key encrypts; the private key decrypts. That asymmetry is the whole point:
- A write-only tier that holds only the public key can store PII it can never read back. An edge service can capture a phone number that only a separate, locked-down service (holding the private key) can ever decrypt.
- Crypto-shredding: deleting the private key renders every masked column permanently unrecoverable, a fast bulk "right to be forgotten" that doesn't require touching a single row.
A Masked value is plaintext in memory when you construct it with Masked::new(...), and ciphertext once it has been loaded from the database. The plaintext is only recoverable through an explicit .reveal() call.
What each output shows
This is the most important table on the page. Masked data behaves differently depending on how it's accessed:
| Access | Result |
|---|---|
Debug ({:?}) | Masked(••••••) (safe in logs) |
Display ({}) | •••••• (safe in templates) |
| serde / JSON output | opaque ciphertext (encrypt-on-serialize) |
.reveal() | the plaintext (needs the private key) |
| the database column | base64 ciphertext (TEXT) |
serde output is ciphertext, not the •••••• marker. This is load-bearing: the ORM write path binds INSERT values via serde_json::to_value(instance), so whatever serde emits is what lands in the column: it must be the encrypted value. The plaintext therefore never leaves the process through serde. For a clean REST response, hide the field with the serializer's .hide([...]); Debug and Display stay redacted regardless, so logs and templates are safe by default.
Set up keys
Generate a keypair once with the built-in CLI command:
cargo run -- maskkeygenIt prints two lines. Add them to your environment (a .env file in dev, your secret manager in prod):
UMBRAL_MASK_PUBLIC_KEY=... # encrypts; every tier that WRITES masked data needs itUMBRAL_MASK_PRIVATE_KEY=... # decrypts / reveal(); keep secret; deleting it crypto-shredsThe keyring is resolved once (an ambient OnceLock, like the DB pool), lazily from the environment on first use. You can also inject it explicitly, useful in tests, or when loading keys from a vault rather than env vars:
use umbral::orm::{MaskKeyring, set_mask_keyring}; set_mask_keyring(MaskKeyring::from_base64(&public_b64, Some(&private_b64))?);A tier configured with only the public key can store masked data but not reveal it: reveal() returns MaskError::NoPrivateKey.
If a key env-var is set but is malformed (bad base64 or not 32 bytes), every seal/reveal fails with MaskError::BadKey - it never silently falls back to storing plaintext. The error is also logged once at first use so a misconfigured key surfaces in the startup logs. (With the public-key env-var simply absent, masking is treated as not configured, and a seal returns NoKeyring.)
Declaring and using a masked field
use umbral::prelude::*; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, Model)]pub struct Customer { pub id: i64, pub name: String, pub phone: Masked<String>, // encrypted at rest, NOT NULL pub backup_phone: Option<Masked<String>>, // nullable; None stays SQL NULL} // Write: plaintext in, sealed on save.Customer::objects() .create(Customer { id: 0, name: "Ada".into(), phone: Masked::new("+254712345678"), backup_phone: None, }) .await?; // Read: the row loads with ciphertext held internally; reveal() decrypts.let c = Customer::objects().filter(customer::ID.eq(1)).first().await?.unwrap();let number = c.phone.reveal()?; // "+254712345678"The #[derive(Model)] macro recognises Masked<String> and Option<Masked<String>> and maps them to a TEXT column with the masked widget. No extra attributes are required.
API reference
Masked<String>
| Item | Description |
|---|---|
Masked::new(plaintext) | Construct from plaintext (sealed on the next write). |
.reveal() -> Result<String, MaskError> | Decrypt. Needs the private key. For an in-memory value, returns it directly without touching the keyring. |
.is_revealable() -> bool | Whether the value can currently be revealed (in-memory, or keyring has a private key). |
Masked::default() | An empty masked value (empty plaintext). |
From<String> / From<&str> | Same as new. |
MaskKeyring
| Item | Description |
|---|---|
MaskKeyring::generate() -> (String, String) | A fresh (public_b64, private_b64) keypair. |
MaskKeyring::from_base64(pub, Some(priv)) | Build a keyring from base64 keys (private optional). |
MaskKeyring::from_env() | Read UMBRAL_MASK_PUBLIC_KEY / UMBRAL_MASK_PRIVATE_KEY. |
.seal(bytes) -> String / .open(ct) -> Result<String, MaskError> | Low-level seal/open against this specific keyring (no ambient state). |
MaskError
NoKeyring (nothing configured), NoPrivateKey (can store but not reveal), BadKey, Malformed, Decrypt (wrong key or tampered data). Implements std::error::Error.
Limitations
You can't filter or sort by a masked column's value
Each encryption uses a fresh ephemeral key, so the same plaintext seals to different ciphertext every time: WHERE phone = '...' can never match. Masked columns are for store-and-reveal, not querying. If you need to look something up by a sensitive value, store a separate non-reversible hash (e.g. SHA-256) in an indexed column and query that.
Masked fields are excluded from auto-generated forms
A Masked field is treated as #[umbral(noform)] automatically: a struct can derive both Model and umbral::forms::Form with masked fields and they're simply skipped (no compile error, no manual attribute). They're considered server-set: set them in your handler with Masked::new(value).
This is deliberate. The generic form pipeline can't safely round-trip a secret on edit: it can't pre-fill the existing value (the stored value is ciphertext, and revealing it into an HTML input would defeat the point), and a blank resubmit is ambiguous between "no change" and "clear it". So rather than half-support a footgun, masked fields stay off forms.
If you genuinely need user input for a masked value (e.g. a phone number on a signup form), collect it as a plain field and wrap it yourself:
// in your handler, from a normal text input:let phone = Masked::new(form.get("phone").cloned().unwrap_or_default());Customer { phone, ..customer }Ciphertext is larger than the plaintext
A sealed box adds ~48 bytes (ephemeral public key + nonce + auth tag) before base64. Fine for PII-sized fields; not intended for large blobs.
GDPR operations
Erasure (right to be forgotten)
Key rotation
Design rationale
See docs/superpowers/specs/2026-06-13-masked-and-oauth-design.md (Component A) for the crypto choice (sealed boxes vs. symmetric AES vs. age), the encrypt-on-serialize integration with the ORM write path, and the GDPR notes.