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.

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: {
    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