Quickback Docs

Masking - Field Redaction

Hide sensitive data from unauthorized users with automatic field masking. Secure PII and confidential fields while maintaining access for authorized roles.

Hide sensitive data from unauthorized users while showing it to those with permission.

Automatic Masking (Secure by Default)

Quickback automatically applies masking to columns that match sensitive naming patterns. This ensures a high security posture even if you don't explicitly configure masking.

Sensitive Keywords & Default Masks

PatternDefault MaskDescription
emailemailp***@e******.com
phone, mobile, faxphone******4567
ssn, socialsecurity, nationalidssn*****6789
creditcard, cc, cardnumber, cvvcreditCard************1234
ibanredact[REDACTED]
password, secret, token, apikey, privatekeyredact[REDACTED]
accesstoken, refreshtoken, clientsecret, signingsecret, bearerredact[REDACTED]
stripe, webhookredact[REDACTED]

Smart Pattern Matching

Auto-masking also matches suffixes like workEmail, homePhone, apiSecret, stripeApiKey, webhookSecret, customerStripe, or orderWebhook.

Build-Time Alerts

If the compiler auto-detects a sensitive column that you haven't explicitly configured, it will emit a warning:

[Warning] Auto-masking enabled for sensitive column "users.email". Explicitly configure masking to silence this warning.

To silence this warning or change the behavior, simply define the field explicitly in your masking configuration. Explicit configurations always take precedence over auto-detected defaults.

Tables without an owner column

The default auto-mask predicate is show: { or: 'owner', roles: ['admin'] } — admins always see the value, the row's owner sees their own. If the table has no owner column at all (no userId / ownerId and no q.scope('owner')), the compiler drops the owner clause and emits a second warning so the rule is still safe but transparent:

[Warning] Auto-masking on "people.email" requested owner OR-show, but "people"
  has no "ownerId" column. Falling back to roles-only (roles: ["admin"]).
  Declare `masking: { email: { show: { roles: [...] } } }` explicitly to
  silence this and pick a real predicate.

This is a real behavioral signal, not boilerplate — the auto-detector can't tell whether the table is "ownerless on purpose" (e.g. a shared lookup table) or "should have an owner column but was authored without one." Declare the predicate explicitly to silence the warning and lock in the intent.

Overriding Defaults

If you want to show a sensitive field to everyone (disable masking) or use a different rule, define it explicitly in the resource.masking block:

export default defineTable(candidates, {
  masking: {
    // Show email to everyone (overrides auto-masking)
    email: { type: 'email', show: { roles: ['everyone'] } },

    // Silence warning but keep secure (recruiter and above only)
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } }
  }
});

Basic Usage

// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    phone:          q.text().optional(),
    resumeUrl:      q.text().optional(),
    organizationId: q.scope('organization'),
  },
  guards: { createable: ["name", "email", "phone", "resumeUrl"], updatable: ["name", "phone"] },
  masking: {
    email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
  },
  read: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
  create: { /* ... */ },
  update: { /* ... */ },
  delete: { /* ... */ },
});

Built-in Mask Types

TypeExample InputMasked Output
'email'john@yourdomain.comj***@y*********.com
'phone'555-123-4567******4567
'ssn'123-45-6789*****6789
'creditCard'4111111111111111************1111
'name'John SmithJ*** S****
'redact'anything[REDACTED]
'custom'(your logic)(your output)

Configuration

masking: {
  // Basic masking - everyone sees masked value
  phone: { type: 'phone' },

  // Show unmasked to specific roles
  email: {
    type: 'email',
    show: { roles: ['hiring-manager', 'recruiter'] }
  },

  // Show unmasked to owner (createdBy === ctx.userId)
  resumeUrl: {
    type: 'redact',
    show: { or: 'owner' }
  },

  // Custom mask function
  externalProfileUrl: {
    type: 'custom',
    mask: (value) => value.slice(0, 4) + '...' + value.slice(-4),
    show: { roles: ['recruiter'] }
  },
}

Show Conditions

show: {
  roles?: string[];    // Unmasked if user has any of these roles
  or?: 'owner';        // Unmasked if user is the record owner
}

The 'owner' condition compares against the owner column declared via q.scope('owner') (or auto-detected from a userId / ownerId column). If no owner scope is present, it falls back to createdBy.

Relationship-roles in masking (v1 limitation)

Relationship-roles declared under authz.roles (e.g. guest: { via: 'guestOf' }) are not yet supported in show: clauses. The compiler refuses to build with a clear error message because async, db-aware mask functions plus a batched relationship matcher (to avoid N+1 on list responses) are required to ship safely — that pipeline has not yet landed. The runtime evaluator fails closed on unrecognized arms, so silent grants are impossible.

Workarounds, depending on intent:

  • Gate on BA roles only: keep masking simple and put the relationship check at the access layer instead — read.views.<v>.access: { roles: ['guest'] } returns a curated field projection that excludes the sensitive column entirely for non-staff callers, without involving masking.
  • Move the field into a view that's gated by the relationship-role at access: same pattern — column-level security via field projection rather than per-row masking.

A future async-mask pipeline with batched lookups will lift this limitation; the schema slot is reserved so the v2 add will be non-breaking.

Masking has a second concern beyond hiding values in the response: a non-admin caller could enumerate masked PII via the query string — e.g. ?filter=email:like:%@target.com — and infer presence even when the response payload is redacted. The query allowlist closes this:

masking: {
  email: {
    type: 'email',
    show: { roles: ['admin'] },
    // Optional — defaults to show.roles when omitted
    query: { roles: ['admin'] },
  },
}

query is a single role gate that covers ?filter, ?sort, and ?search uniformly. When omitted, it inherits from show.roles — "if you can see it, you can query it" — which is the right shape for almost all PII.

A view can opt back in for a higher-privileged role pair by listing the column in its own query.{filterable,searchable,sortable} allowlist. The compiler validates at build time that the view's access roles intersect the masking query.roles (or show.roles when query is omitted), so the opt-in can never reach a caller who'd see the value masked.

masking: {
  email: { type: 'email', show: { roles: ['admin'] } },
  // query: defaults to show.roles → ['admin']
},
read: {
  views: {
    pipeline: {
      fields: ['id', 'name'],
      access: { roles: ['member', 'admin'] },
      query: {
        searchable: ['name'],   // email NOT listed → not searchable on this view
      },
    },
    full: {
      fields: ['id', 'name', 'email'],
      access: { roles: ['admin'] },
      query: {
        filterable: ['email'],  // ✓ admin satisfies email's query gate
        searchable: ['name', 'email'],
      },
    },
  },
},

query replaces the legacy per-capability flags (filterable, searchable, sortable). Setting any of those is rejected at compile time with a migration hint.

Egress protection

Masking redacts a column on the read paths (GET /:id, list, views, realtime) per role. It does not automatically follow the value if you copy it into a derived artifact that bypasses those read paths — a different record, an html/text string, or an outbound payload. Quickback guards those egress points so a masked token/PII can't quietly ship in the clear:

  • Compile-time block (declarative). A masked column is rejected at build time if it appears in embeddings.fields / embeddings.metadata (the value would be embedded and stored beside the vector, and returned in search hits) or is used as the table's displayColumn (it would render as the FK label inside every referencing record).
  • Compile-time scan (actions). A record-based action that copies record.<maskedColumn> into another record's column, an html/text template that gets stored, or a payload object fails compilation. Plain reads, comparisons, logging, and tagged templates (sql`…${record.col}` ) are fine — only the storage/payload sinks are flagged.
  • Realtime → webhook. Rows pushed to a realtime webhook sink are masked (an external sink carries no role, so masked columns are redacted), matching what WebSocket subscribers see.

Revealing a value on purpose: unmask()

When an action legitimately needs the raw value (e.g. calling an external API with a token), route it through unmask() — the sanctioned, audited reveal. It returns the raw value and records a best-effort masked_reveal entry in the audit database when one is configured.

import { unmask } from '../../../lib/masking-reveal';

// in an action's execute():
const token = await unmask(c, record.apiToken, 'apiToken');
await fetch(externalUrl, { headers: { Authorization: `Bearer ${token}` } });

Opting a column out: egress: 'allow'

If a column is masked for display tidiness rather than secrecy, opt it out of both the compile-time block and the action scan:

masking: {
  shortCode: { type: 'redact', show: { roles: ['admin'] }, egress: 'allow' },
}

egress defaults to 'block' (fail-closed). Note the action scan is a heuristic — it catches the obvious accidental copy (record.col straight into a sink) but not values first aliased into another variable, the whole row returned/spread, or a re-fetch via db.select(). Treat it as a guardrail against accidents, not a cryptographic boundary; for true secrecy use the encryption pillar.

Complete Example

// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required().filterable('like').searchable(),
    email:          q.text().required().filterable('eq').searchable(),
    phone:          q.text().optional().filterable('eq'),
    resumeUrl:      q.text().optional(),
    organizationId: q.scope('organization'),
  },
  guards: {
    createable: ['name', 'email', 'phone', 'resumeUrl'],
    updatable:  ['name', 'phone'],
  },
  masking: {
    email:     { type: 'email',  show: { roles: ['admin+'] } },
    phone:     { type: 'phone',  show: { roles: ['admin+'] } },
    resumeUrl: {
      type: 'custom',
      mask: () => '[RESUME LINK HIDDEN]',
      show: { roles: ['admin+'] },
    },
  },
  read: {
    // "+" role expansion requires auth.roleHierarchy in quickback.config.ts
    access: { roles: ['member+'] },
    views: {
      pipeline: {
        fields: ['id', 'name'],
        access: { roles: ['member+'] },
        query: { searchable: ['name'] },
      },
      full: {
        fields: ['id', 'name', 'email', 'phone', 'resumeUrl'],
        access: { roles: ['admin+'] },
        query: {
          filterable: ['email', 'phone'],
          searchable: ['name', 'email'],
        },
      },
    },
  },
  create: { access: { roles: ['admin+'] } },
  update: { access: { roles: ['admin+'] } },
  delete: { access: { roles: ['owner'] }, mode: 'soft' },
});

On this page