The problem this decision solves
A single role check in a controller works until it doesn't: the second team that queries the table, the read replica the analytics job hits, the export job that forgot the filter. Masking that lives in application code is only as strong as the last engineer who remembered to add it.
The column masking PoC on this site tests the narrow version of this problem: one table, three roles, one query shape. This note is the scaled-up version: hundreds of columns, dozens of roles and partner integrations, multiple services reading the same tables, and a compliance team that needs to answer "who could have seen this field on this date" without reading source code.
Two mechanisms, one decision
Masking
The underlying value never leaves the database in readable form for an unauthorized caller. A dynamic view, a proxy, or a row/column security policy substitutes a redacted, truncated, or synthetic value at read time. Masking is usually one-directional. There is no path back to the original from the masked output.
Tokenization
The original value is replaced with a token that is meaningless outside a vault, and the mapping is stored separately from every other system that uses the token. An authorized caller can detokenize; everyone else only ever holds a token. Tokenization is reversible by design, which is exactly why the detokenization path needs its own policy decision.
They are not competing choices. A payment field is usually tokenized so a support agent can reference "card ending 4242" without ever seeing the PAN, while a diagnosis field is usually masked outright for anyone outside clinical care, because there is no legitimate reason for billing to reconstruct it.
Evaluation criteria
Policy expressiveness
Can the engine express attribute-based rules (role, data classification, purpose of use, time of day, case status) rather than a static role-to-table map?
Decision latency
Is the authorization check cheap enough to run per-column, per-request, without becoming the slowest part of the query path?
Auditability
Can every permit or deny be reconstructed later, including which policy version, which principal attributes, and which resource, without replaying application logs?
Reversibility control
Is detokenization itself a policy-gated action with its own audit trail, rather than an implicit side effect of holding a database credential?
Blast radius of a bug
If a policy is wrong, does it fail open or closed? A default-deny engine turns a missing rule into an incident caught in staging instead of a silent leak.
Integration surface
Does the enforcement point sit at the database boundary, the API gateway, or both, and does that match where the data actually fans out to other services?
Where Cedar fits
Cedar is an open-source, schema-validated authorization language originally built for Amazon Verified Permissions and released independently of it. It is deliberately narrow: it does not run business logic; it answers one question, can this principal take this action on this resource, given this context? The answer is always Allow, Deny, or an explicit reason. Policies are permit or forbid statements, evaluation is default-deny, and any matching forbid overrides every permit. That last rule is what makes Cedar a good fit for masking: the default state of a new column is invisible, and a forbid for a sensitive field can't be quietly outvoted by a broad role grant written somewhere else.
Cedar policies can be unit-tested and schema-validated in CI the same way a type system catches a bad call: a policy that references a role or a resource attribute the schema doesn't define fails to compile, before it ever reaches production.
Field-level entitlement, in policy
The pattern is to model each protected column as a resource the principal is requesting to view, not to try to encode masking rules inside SQL. Enforcement then asks Cedar one question per field, or a batch of them, and only reveals the fields that came back Allow.
| Cedar concept | Maps to |
|---|---|
| Principal | The authenticated caller — User::"agent-victor" — with role and department attributes |
| Action | Action::"viewField", one per read, or Action::"detokenize" for the reversible path |
| Resource | A specific column instance — Field::"patient_record.diagnosis" — tagged with a data classification |
| Context | Request-time facts the policy can condition on — case status, time of day, whether MFA was used this session |
Cedar policy set (excerpt): field-level entitlement
// Schema (excerpt): what a Field resource carries
entity Field {
table: String,
classification: String, // "clinical" | "billing" | "support" | "restricted"
};
entity User in [Role] {
department: String,
};
entity Role;
// Default posture: nothing is visible unless a policy says otherwise.
// Clinical staff see clinical-classified fields for records tied to their department.
permit (
principal in Role::"Clinical",
action == Action::"viewField",
resource
)
when { resource.classification == "clinical" };
// Billing sees billing fields, and tokenized payment references, never raw PANs.
permit (
principal in Role::"Billing",
action == Action::"viewField",
resource
)
when { resource.classification == "billing" };
// Support sees coordination fields only — explicitly walled off from restricted data,
// even if a future role grant would otherwise have covered it.
forbid (
principal in Role::"PatientSupport",
action == Action::"viewField",
resource
)
when { resource.classification == "restricted" };
// Detokenizing a payment token is its own action, gated separately from
// viewing the tokenized field, and only inside an open, unresolved case.
permit (
principal in Role::"Billing",
action == Action::"detokenize",
resource
)
when {
resource.classification == "billing" &&
context.case.status == "open"
};
Notice what the application never does: it never asks "is this user Billing." It asks Cedar "can this principal view this field, right now, given this context," and renders whatever comes back. The role-to-field mapping lives in one reviewable place instead of being reconstructed from scattered conditionals.
Reference architecture
Cedar makes the decision; something else enforces it. Production systems need both, kept as separate services so a masking bug and an authorization bug fail independently and get diagnosed independently.
The database itself should never be the last line of defense on its own. The masking view or column proxy enforces what Cedar decided, and the token vault is the only place that can map a token back to a value; a compromised application credential should only ever yield tokens and masked values.
Side by side
| Approach | Reversible | Best fit | Primary concern |
|---|---|---|---|
| Static masking (ETL-time) | No | Non-production copies, analytics sandboxes | Source of truth still holds raw data |
| Dynamic masking (views / RLS) | No | Read paths where the raw value is never needed by that caller | Every read path must go through the view, not the base table |
| Tokenization (vaulted) | Yes, via a gated detokenize action | Payment data, identifiers referenced across many systems | Vault becomes a second system of record to secure and operate |
| Format-preserving encryption | Yes, with the key | Fields where downstream systems validate shape (length, digits) | Key management carries the entire security guarantee |
Production checklist
- Classify every column before writing a single policy: masking rules are only as good as the classification pipeline behind them.
- Validate every Cedar policy against a schema in CI, so an unknown resource or action fails the build well before it can become a runtime surprise.
- Default deny: a new column or a new role should be invisible until a policy explicitly grants it.
- Put detokenization behind its own action and its own audit trail; never treat it as implied by "can view this field."
- Cache decisions at the request or session grain, not the field grain, so per-column checks don't dominate query latency.
- Log every deny as well as every allow; a pattern of denies against one principal is often the earliest signal of a compromised credential.
- Separate the token vault's operational access from the application database's, so the two never share an admin path.
- Test the policy set the way you'd test code: known-good grants, known-bad denials, and regression cases for every incident you've already had.
- Pilot on one high-sensitivity table before generalizing the pattern; the column masking PoC is that pilot at a single-table scale.