Data masking
Column-level redaction on the read path — the companion to row-level security.
Last updated:
Dynamic data masking controls which column values a caller sees in the
clear. Where row-level security decides which rows a
procedure returns, masking decides which fields within those rows come back
redacted. Both are server-side, both attach to a procedure's .use(...) chain,
and both are opt-in per procedure: a procedure without .use(mask(...))
sees raw values, even if another procedure masks the same column.
import { mask } from "lunorash/server";
import { query } from "./_generated/server";
export const listUsers = query.use(mask({ users: { email: "redact" } })).query(async ({ ctx }) => ctx.db.findMany("users"));
// every `email` in the result comes back as nullStrategies
A policy maps table → column → strategy. Three strategies:
| Strategy | Result |
|---|---|
"redact" | The value becomes null. Simplest and safest. |
"hash" | A deterministic, non-reversible token (FNV-1a). The same input always yields the same token, so values stay joinable/groupable client-side without revealing the original. Not cryptographic; it's a masking token, not a digest. |
MaskFn | A custom transform (value, context) => masked that can branch on the caller's roles or sibling columns. |
export const listUsers = query
.use(
mask({
users: {
email: "redact",
phone: "hash",
ssn: (value, { auth }) => (auth.can("pii:view") ? value : "•••"),
},
}),
)
.query(async ({ ctx }) => ctx.db.findMany("users"));Roles and bypass
A MaskFn receives a context with an auth view — auth.can(permission),
auth.roles, auth.userId, auth.identity — so masking can be role-aware. Wire
the permissions that back auth.can(...) through the roles option, and use
bypass to skip masking entirely for privileged callers:
import { mask, definePermission, defineRole } from "lunorash/server";
import { query } from "./_generated/server";
const viewPii = definePermission("pii:view");
const support = defineRole("support", { permissions: [viewPii] });
export const listUsers = query
.use(
mask(
{ users: { email: "redact", phone: "redact" } },
{
roles: [support],
bypass: ({ auth }) => auth.can("pii:view"), // privileged callers see raw values
},
),
)
.query(async ({ ctx }) => ctx.db.findMany("users"));A MaskFn also sees the original, un-masked row as context.row, so it can
branch on a sibling column:
mask({
users: {
// hide email unless the row itself is an admin account
email: (value, { row }) => (row?.role === "admin" ? value : null),
},
});What's masked, what isn't
- Read path is wrapped.
get,findFirst/findFirstOrThrow,findMany, thequery().withIndex().order().collect()/first()/take()/paginate()chain, andrankPageall rewrite the declared columns of each top-level row. - Nested relations are not masked. Rows hydrated through a
withclause sit below the facade the mask wraps, the same boundary RLS draws. - Writes pass through untouched.
insert,patch,replace, anddeleteare never wrapped; stored data is never altered. Masking is an output filter. - Aggregates fail closed.
aggregate()andgroupBy()over a masked column throwMASK_UNSUPPORTED(HTTP 422): a group key is the raw value and an aggregate is computed from it, so neither can be served without leaking.count,rank, andrankBefore(which return counts, not values) still work. - A throwing
MaskFnredacts tonull. It never leaks the raw value.
Tooling
- Studio — the data browser shows a "Mask
sensitive columns" toggle that previews redact/hash/custom output client-side
and flags masked columns in the grid header. Codegen emits the
(table, column, strategy)map it reads. - Advisor — the
mask_uncovered_pii_columnlint warns when a public procedure reads a table whose columns are masked elsewhere but the procedure has no.use(mask(...)), the opt-in gap that would return PII in the clear.
See also
- Row-level security — the row-path companion that shares roles/permissions with masking.
- Advisors — the
mask_uncovered_pii_columnlint. - Studio — the masking preview in the data browser.