Masking - Field Redaction
Hide sensitive data from unauthorized users with field masking. The compiler detects sensitive column names and makes you decide, per column, whether to mask them.
Hide sensitive data from unauthorized users while showing it to those with permission.
Detection (the compiler asks, you decide)
Quickback scans every table for columns whose names match its sensitive-column patterns — email, ssn, apiKey, stripeSecret, refreshToken, and the rest of the table below.
It does not decide what to do about them. It refuses to compile until you do:
customers: 2 columns match Quickback's sensitive-column patterns but carry no masking decision.
Quickback will not guess whether a value is confidential — you decide, per column:
- email { type: 'email', show: { or: 'owner', roles: ['admin'] } }
- apiKey { type: 'redact', show: { or: 'owner', roles: ['admin'] } }
Add a `masking` block to "customers". Keep the suggested rule to mask the
column, or set `false` to record "reviewed — not sensitive here" (the value is then
returned in full and stays filterable / sortable / searchable):
masking: {
email: { type: 'email', show: { or: 'owner', roles: ['admin'] } }, // mask it
// email: false, // …or this instead: reviewed, not sensitive here
apiKey: { type: 'redact', show: { or: 'owner', roles: ['admin'] } }, // mask it
// apiKey: false, // …or this instead: reviewed, not sensitive here
},
Every detected column needs one or the other — an omitted key is not a decision.Paste the block, delete the wrong line from each pair, and you're done:
masking: {
// Yes — mask it. Redacted in responses, and only `admin` may filter/sort/search it.
apiKey: { type: 'redact', show: { roles: ['admin'] } },
// No — reviewed, not sensitive here. Returned in full, freely queryable.
email: false,
}Why a hard error and not a default
Whether customers.email is PII to hide or the display label of a contact list is a product decision, and the compiler has no way to know which. Both possible defaults are wrong somewhere:
- Auto-mask silently and you ship a broken list view, or you think a value is protected because a warning said so.
- Ignore it silently and a
stripeSecretcolumn leaks in every response.
So the compiler asks. A false positive costs you one line (cc: false) — which is also a durable, reviewable record that somebody looked.
It also removes a bug class outright. When detection can never quietly supply a rule, the set of masked columns is the set you declared, so no two surfaces can disagree about it. That exact disagreement was a real defect: through v0.58.3 the response path applied declared-only masking while realtime and live views applied detected-plus-declared, so an auto-detected secret was redacted over WebSocket and returned in plaintext on GET.
What a decision controls
| Surface | { type, show } rule | false |
|---|---|---|
GET /:id, list, named views, POST, PATCH, PUT, batch | Masked per role | Returned in full |
| Realtime frames + live-view deltas | Masked per-subscriber-role | Sent in full |
?filter= / ?sort= | Only roles in the query gate | Unrestricted |
?search= | Dropped from the default searchable set | Searchable |
FK display label / embeddings / action egress scan | Blocked — see Egress protection | Allowed |
Sensitive Keywords & Default Masks
| Pattern | Default Mask | Description |
|---|---|---|
email | email | p***@e******.com |
phone, mobile, fax | phone | ******4567 |
ssn, socialsecurity, nationalid | ssn | *****6789 |
creditcard, cc, cardnumber, cvv | creditCard | ************1234 |
iban | redact | [REDACTED] |
password, secret, token, apikey, privatekey | redact | [REDACTED] |
accesstoken, refreshtoken, clientsecret, signingsecret, bearer | redact | [REDACTED] |
stripe, webhook | redact | [REDACTED] |
Smart Pattern Matching
Detection also matches suffixes like workEmail, homePhone, apiSecret, stripeApiKey, webhookSecret, customerStripe, or orderWebhook. Every match needs a decision — including the aggressive ones. cc: false on an accounting table's carbon-copy column is a one-line answer.
The rule shown in the error is a suggestion, not a default that applies on its own — nothing is masked until you write it down.
Tables without an owner column
The suggested predicate is show: { or: 'owner', roles: ['admin'] } — admins always see the value, the row's owner sees their own. If you keep it on a table with no owner column at all (no userId / ownerId and no q.scope('owner')), the compiler drops the owner clause and warns, 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.Drop the or: 'owner' clause and pick a real predicate on an ownerless table.
Choosing the rule
If you want to show a sensitive field to everyone, or use a different rule, that's just what you write in the resource.masking block:
export default defineTable(candidates, {
masking: {
// Masked, but visible to everyone (a rule with a wide-open audience)
email: { type: 'email', show: { roles: ['everyone'] } },
// Recruiter and above only
phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
// Not masked at all, and not query-restricted either
linkedinUrl: false,
}
});Your rule replaces the suggestion outright — the suggested rule is never merged into yours. That also means the rule is where you set the query gate: it defaults to show.roles, so a list UI that sorts by email for every signed-in member needs the roles spelled out.
masking: {
email: {
type: 'email',
show: { roles: ['admin'] }, // still redacted in the response body…
query: { roles: ['member', 'admin'] }, // …but members may sort/filter by it
},
}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'),
...q.audit(),
...q.softDelete(),
},
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
| Type | Example Input | Masked Output |
|---|---|---|
'email' | john@yourdomain.com | j***@y*********.com |
'phone' | 555-123-4567 | ******4567 |
'ssn' | 123-45-6789 | *****6789 |
'creditCard' | 4111111111111111 | ************1111 |
'name' | John Smith | J*** 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-aware field projection
For relationship-specific field visibility, use a named view gated by the relationship role. The relationship resolver runs once at the access layer and the view projects only the fields that caller may receive.
Quickback deliberately does not run per-row database lookups from
masking.<field>.show: doing so would turn list masking into an N+1 query
pipeline. A relationship-backed show.roles entry is therefore a clear
compile-time refusal, never a silently ignored condition or a broader grant.
Named gates in masking
Named gates (authz.rules) follow the same discipline in show::
- A who-only gate expands like a named role (its flat role list; the owner-gated flat-role constraint above applies).
- A gate carrying a
record:predicate is a compile error — masking cannot evaluate record conditions, so the record dimension would be silently dropped. - A gate carrying firewall lanes is a compile error — masking has no firewall array to satisfy, so the gate's row lanes would silently not apply. Reference a who-only rule (or inline the roles) in
show:, and stamp the firewall gate on the resource'sfirewall:instead.
Supported patterns, 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.
The schema slot remains reserved for a future batched async-mask pipeline, so adding that execution mode will not require changing the surrounding masking shape.
Query Gate (filter / sort / search)
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.
A column you answered with false is not gated at all — it stays freely filterable, sortable and searchable, which is the point of saying "not sensitive here".
The build-time check sees every masked column
Because the decision gate guarantees every detected column is in your masking block, this view check covers all of them. There is no category of "masked but invisible to the validator" — that gap existed only while detection could silently supply rules the validator never saw.
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 is the unified replacement for the legacy per-capability masking flags
(filterable, searchable, sortable). If an older definition still uses
one, the compiler stops with the exact migration to the single role gate.
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'sdisplayColumn(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.
Egress protection follows your decision
These checks read your masking block, which the decision gate guarantees covers every detected column. A column answered with a rule is egress-protected; a column answered false is not — you reviewed it and said it isn't confidential, so copying it into an embedding or an outbound payload is allowed.
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). The action scan is an
accident-prevention layer: it catches direct record.col → sink flows without
pretending to be a whole-program information-flow proof. Aliased values,
whole-row spreads, and values re-fetched through db.select() remain ordinary
application code. Pair masking with the encryption
pillar whenever the field needs a
cryptographic boundary at rest or from the server itself.
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'),
...q.audit(),
...q.softDelete(),
},
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' },
});Access - Role & Condition-Based Access Control
Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.
Rate Limit - Per-Operation Request Caps
Per-resource, per-operation rate limiting backed by Cloudflare's native Rate Limiting binding. Defaults apply to every resource automatically; override or opt out per-resource or project-wide.