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. count, rank, and rankBefore (which return counts, not values) still work.
  • 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.