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. - Filtering and sorting by a masked column fail closed too. A
where/filterthat references a masked column also throwsMASK_UNSUPPORTED(HTTP 422), onfindMany,findFirst, andcountalike. A predicate likewhere: { ssn: { eq: X } }(row present ⇒ value confirmed) or a range check turns the mask into a value oracle, so it is rejected. You can't filter or sort by a value the mask hides. The server-injected RLS filter is exempt: it is server-trusted, not a caller-supplied oracle. - Plain counts still work. A
count,rank, orrankBeforethat returns a count of rows (no column value, no masked-columnwhere) passes through; only acountwhosewherereferences a masked column throws. - 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.