Skip to content
DocsconceptsDocumentation

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 null

Strategies

A policy maps table → column → strategy. Three strategies:

StrategyResult
"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.
MaskFnA 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, the query().withIndex().order().collect()/first()/take()/paginate() chain, and rankPage all rewrite the declared columns of each top-level row.
  • Nested relations are not masked. Rows hydrated through a with clause sit below the facade the mask wraps, the same boundary RLS draws.
  • Writes pass through untouched. insert, patch, replace, and delete are never wrapped; stored data is never altered. Masking is an output filter.
  • Aggregates fail closed. aggregate() and groupBy() over a masked column throw MASK_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 / filter that references a masked column also throws MASK_UNSUPPORTED (HTTP 422), on findMany, findFirst, and count alike. A predicate like where: { 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, or rankBefore that returns a count of rows (no column value, no masked-column where) passes through; only a count whose where references a masked column throws.
  • A throwing MaskFn redacts to null. 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_column lint 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_column lint.
  • Studio: the masking preview in the data browser.