Snowflake Data Masking and Row Access Policies in Practice
How Snowflake masking policies, row access policies, and secure views actually work: syntax, role-based patterns, defense-in-depth, auditing, and the pitfalls that bite in production.
Snowflake
This post was written by an engineer at QueryPlane. QueryPlane is an app builder for your database: bring your own postgres db and you can create interactive applications to share with other developers, coworkers or even your customers. If you’re interested in trying it out, get started here.
A Snowflake account that holds production customer data inevitably ends up answering the same governance question: who is allowed to see what. The naive answer is “use roles, grant SELECT on the right tables”. That answer holds until the first analyst needs read access to the customers table to build a churn dashboard but should not see email addresses, or a multi-tenant platform team needs every analyst to only see rows for their own customers without copying the table. At that point you need column-level masking and row-level filtering, and Snowflake gives you both as first-class objects: masking policies and row access policies.
This post walks through both, focusing on the patterns teams actually deploy in production rather than the most basic syntax examples in the docs. The two policy types compose: a masking policy can hide a column’s value, a row access policy can hide rows entirely, and applied together they let you give one analyst role a single SQL surface that returns different data depending on who is asking.
In this post, we’ll cover:
- The three primitives — masking policies, row access policies, and where secure views still fit in
- Dynamic data masking — syntax, conditional logic, partial masking patterns
- Row access policies — multi-tenant filtering, mapping tables, intersecting predicates
- Composing the two — column masking + row filtering on the same table
- Performance and pruning — when policies block partition pruning and how to keep clustering useful
- Auditing —
POLICY_REFERENCES,ACCESS_HISTORY, and verifying coverage - Deployment patterns — Terraform, version control, blue/green policy rollouts
The three primitives
Snowflake exposes three governance primitives that overlap in scope and confuse people who first encounter them.
Masking policies (docs) operate on individual columns. A policy is a SQL expression that takes the original column value and returns a masked value, evaluated at query time based on the session context. The same SELECT email FROM customers returns the raw email for a privileged role and *****@example.com for everyone else, without rewriting the query or maintaining a parallel masked column. Snowflake calls this dynamic data masking because the masking happens dynamically per query, not in a separate copy of the data.
Row access policies (docs) operate on whole rows. A policy is a boolean SQL expression that takes one or more columns from the table and returns TRUE for rows the current session is allowed to see. Rows where the expression returns FALSE are filtered out before any aggregation, join, or projection — the analyst sees a smaller table, not a denial.
Secure views (docs) are an older but still useful primitive: a CREATE SECURE VIEW definition hides the view’s text from non-owners and prevents the optimizer from leaking row contents through error messages or query plan shapes. They were the only column/row protection mechanism for years before masking and row access policies shipped, and they’re still the right answer when the policy logic is too dynamic for a masking expression — for example, joining the view body against a per-customer config table whose result determines which columns to return.
The decision rule we use: if you can express the rule as “this column shows X to role A and Y to everyone else”, use a masking policy. If you can express it as “this role can see rows where condition C is true”, use a row access policy. If the rule depends on a more complex join or query-shape change, use a secure view. The first two are the modern defaults; secure views are the fallback when policies aren’t expressive enough.
Dynamic data masking
The unit is the masking policy — a named SQL expression that takes the original value and returns the value the caller is allowed to see.
CREATE OR REPLACE MASKING POLICY email_mask AS (val STRING)
RETURNS STRING ->
CASE
WHEN CURRENT_ROLE() IN ('ANALYTICS_FULL', 'SECURITYADMIN') THEN val
WHEN CURRENT_ROLE() = 'ANALYTICS_PARTIAL' THEN REGEXP_REPLACE(val, '.+@', '*****@')
ELSE '***MASKED***'
END;
The policy lives in a schema like any other object. Apply it to a column with ALTER TABLE:
ALTER TABLE customers
MODIFY COLUMN email SET MASKING POLICY email_mask;
From that moment, every query that reads customers.email runs the policy expression instead of returning the raw column value. A query like SELECT email FROM customers WHERE id = 42 returns alice@example.com for a session whose CURRENT_ROLE() is ANALYTICS_FULL, and *****@example.com for ANALYTICS_PARTIAL. The query text is identical; the data returned isn’t.
The same policy can be attached to multiple columns and tables. If email appears on customers, orders.billing_email, and support_tickets.contact_email, one policy applied three times keeps the rule consistent across all of them. The opposite is also true: a column can carry exactly one masking policy at a time, so the policy expression has to encode every variation of masking you need for that column.
Conditional masking based on session context
The most useful column inside a masking expression is CURRENT_ROLE() — the role the user activated for the session — but Snowflake exposes several other context functions (docs) that make policies more expressive:
CREATE OR REPLACE MASKING POLICY ssn_mask AS (val STRING)
RETURNS STRING ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
WHEN CURRENT_REGION() IN ('AWS_EU_WEST_1', 'AWS_EU_CENTRAL_1')
AND IS_ROLE_IN_SESSION('EU_DPA_REVIEWER') THEN val
WHEN INVOKER_SHARE() IS NOT NULL THEN NULL
ELSE 'XXX-XX-' || RIGHT(val, 4)
END;
A few things worth knowing here. IS_ROLE_IN_SESSION returns TRUE if the named role is granted to the current role through any path in the role hierarchy — useful when you want to grant unmask access to anyone under a parent role rather than enumerating every leaf role. CURRENT_REGION lets you write rules that fire only in a specific Snowflake region, which is the cleanest way to enforce data residency for GDPR-style regulations. INVOKER_SHARE returns the name of the share when the query runs through a data share — returning NULL for shared queries is a common pattern for ensuring shared consumers never see raw PII even when the producer’s own analysts can.
For partial reveal, two patterns recur often enough to memorize. Last-N masking preserves enough of the value to be useful for support workflows: '***-**-' || RIGHT(val, 4) on a Social Security Number, or LEFT(val, 1) || '***' || RIGHT(val, 1) || '@' || SPLIT_PART(val, '@', 2) on an email. Hash masking returns a deterministic but irreversible value: SHA2(val, 256) or HASH(val) so that downstream joins on the masked column still work, but the original cannot be recovered. Hashing is the right call when an analyst needs to join a masked PII column against another masked PII column for cohort analysis without ever seeing the underlying values.
Common masking-policy patterns
Three patterns cover most production deployments. The first is the straight redaction — replace the value with a literal string for non-privileged roles, return the original for the unmask role. The second is the format-preserving mask — keep the shape of the value (length, character classes, domain suffix on emails) so that downstream BI tools and joins behave normally, but the sensitive bits are obscured. The third is the null mask — return NULL for non-privileged roles, which is the right call when the analyst should be able to see that the column exists and run aggregates like COUNT(email) against a known-zero population, but shouldn’t see any actual values.
A subtle trap with masking policies: the policy runs at query time, not at write time. A view created on top of customers.email will return the masked value when read by an analyst, but the underlying column on disk still contains the raw email. If your compliance requirement is “the data must not exist in cleartext at rest”, masking policies are not the right tool — you want client-side encryption or external tokenization where the raw value never enters Snowflake in the first place.
Row access policies
A row access policy is a SQL boolean expression that takes one or more columns of a table as arguments and returns TRUE for rows the session is allowed to see. The policy is evaluated as if it were an extra predicate appended to every query that reads the table.
CREATE OR REPLACE ROW ACCESS POLICY orders_by_region AS (region STRING)
RETURNS BOOLEAN ->
CASE
WHEN CURRENT_ROLE() = 'GLOBAL_ANALYST' THEN TRUE
ELSE region = SPLIT_PART(CURRENT_ROLE(), '_', 1)
END;
ALTER TABLE orders
ADD ROW ACCESS POLICY orders_by_region ON (region);
After the ALTER TABLE, a session running as EU_ANALYST sees only rows where region = 'EU'. A session running as GLOBAL_ANALYST sees everything. No analyst has to remember to add WHERE region = ... — the policy does it automatically, and importantly, the policy applies to joins and subqueries as well, so there is no SQL pattern that bypasses it.
The example above works because the rule is simple enough to encode in the policy expression. For multi-tenant filtering where the role-to-tenant mapping is dynamic, the standard pattern is a mapping table:
CREATE TABLE governance.role_tenant_map (
role_name STRING,
tenant_id NUMBER
);
INSERT INTO governance.role_tenant_map VALUES
('TENANT_42_ANALYST', 42),
('TENANT_42_ANALYST', 43), -- this role can see both 42 and 43
('TENANT_99_ANALYST', 99);
CREATE OR REPLACE ROW ACCESS POLICY orders_by_tenant AS (tenant_id NUMBER)
RETURNS BOOLEAN ->
EXISTS (
SELECT 1
FROM governance.role_tenant_map m
WHERE m.role_name = CURRENT_ROLE()
AND m.tenant_id = tenant_id
)
OR CURRENT_ROLE() IN ('SECURITYADMIN', 'PLATFORM_ENG');
ALTER TABLE orders ADD ROW ACCESS POLICY orders_by_tenant ON (tenant_id);
Onboarding a new tenant becomes a single INSERT INTO governance.role_tenant_map instead of editing the policy. The same policy can be applied to every tenant-scoped table by listing the appropriate tenant column on each ALTER TABLE:
ALTER TABLE orders ADD ROW ACCESS POLICY orders_by_tenant ON (tenant_id);
ALTER TABLE invoices ADD ROW ACCESS POLICY orders_by_tenant ON (tenant_id);
ALTER TABLE shipments ADD ROW ACCESS POLICY orders_by_tenant ON (tenant_id);
A row access policy can take multiple columns as arguments — RETURNS BOOLEAN -> ... over (region, tenant_id, classification) is valid. The policy expression has access to every argument and to context functions, so rules like “EU analysts can only see EU customers, except for the CLASSIFIED rows which only the security team can see” fit naturally into a single expression.
One important constraint: a table can have at most one row access policy attached at a time. To enforce multiple independent rules — “filter by region AND filter by classification AND filter by data product” — you encode all the rules into a single policy expression rather than stacking policies. This is annoying when rules come from different governance teams, and the standard workaround is to express each rule as a helper function (a secure UDF) that the policy expression composes with AND.
See what QueryPlane can build for you
Connect to your database, write SQL with AI, and build shareable apps — all from your browser.
Composing masking and row access
The two policy types apply independently and stack naturally. A masking policy on customers.email and a row access policy on customers.tenant_id both fire on a query like SELECT email FROM customers. The row access policy filters rows first, so an analyst with access to tenant 42 only sees email values for tenant-42 customers, and the masking policy then decides whether the email values themselves are returned in cleartext or masked.
The order matters for performance reasoning but not for security. From the analyst’s perspective, the table looks exactly like a smaller, partially-redacted version of itself. From the optimizer’s perspective, the row access predicate is pushed into the scan when possible and the masking expression is evaluated on the projection. Both happen inside Snowflake; neither runs in your client.
Three patterns recur in production composition:
The defense-in-depth pattern attaches a row access policy that filters to “rows the role legitimately needs” and a masking policy that masks any PII inside those rows. Even if the row access policy is misconfigured (an analyst is added to too many tenants in the mapping table), the masking policy still hides email and SSN. The two failures have to compose for raw PII to leak, which is the right shape for compliance.
The null-for-out-of-scope pattern uses masking policies that return NULL when CURRENT_ROLE() is outside the privileged set and a row access policy that is generous with row visibility. Analysts can see the row exists, can count rows, can group by other columns, but the sensitive columns are blanked. This is a good fit for cohort analysis on healthcare or financial data where the rule is “you can see aggregate statistics on everyone but not identifiable details on anyone”.
The role-based view fan-out pattern attaches a row access policy + masking policies to one table and creates secure views on top for analyst-friendly access. The view definitions are the documented contract; the policies are the enforcement. Analysts query views; the views inherit the underlying table’s policies; the data they see is automatically filtered and masked without anyone having to read the policy SQL.
Secure views vs masking policies
Secure views predate masking and row access policies and remain the right answer for a narrow set of cases. A CREATE SECURE VIEW v AS SELECT ... hides the view definition from non-owners and prevents the optimizer from leaking row contents through join order, predicate push-down, or error messages (docs). The cost is that the optimizer also gets less freedom — secure views can’t be expanded into the surrounding query plan as aggressively as regular views, so they can be slower.
The clearest rule: if the protection you need is “this user shouldn’t see column X” or “this user shouldn’t see rows where condition Y”, masking and row access policies are the modern answer. If the protection you need is “this user shouldn’t see anything about how the table is structured, including the SQL that built the view they’re querying”, secure views are still required. The two compose — a secure view on top of a table with masking and row access policies inherits all three layers — and most production governance setups end up using all three for the small surface where they overlap.
Performance and pruning
Both policy types add work to query execution, and the magnitudes are very different.
A masking policy runs once per row in the result set during projection. The expression is usually cheap (CASE on CURRENT_ROLE(), a REGEXP_REPLACE, a hash), and Snowflake evaluates it efficiently in the columnar engine. In our experience, a typical masking policy adds well under 5% to query wall time on the analyst’s path. The cost is real but it is not the right thing to worry about.
A row access policy is more expensive because it changes the shape of the scan. Snowflake’s main read-time optimization is partition pruning — skipping micro-partitions whose min/max metadata doesn’t match the predicate. A simple row access policy like region = 'EU' is pushed into the scan and prunes partitions normally. A complex policy involving a join to a mapping table is harder for the optimizer to push down, and can force a full scan of the table where an analyst-written WHERE would have pruned.
The fix is not to abandon mapping tables — they are the right design for multi-tenant rules — but to keep the policy expression cheap in the common case. A common technique is the fast path: structure the policy so the first branch of a CASE returns TRUE for the privileged role (constant-time) and the slower mapping-table lookup only runs for the restricted roles:
CREATE OR REPLACE ROW ACCESS POLICY orders_by_tenant AS (tenant_id NUMBER)
RETURNS BOOLEAN ->
CURRENT_ROLE() = 'PLATFORM_ANALYST' -- fast path
OR EXISTS (
SELECT 1 FROM governance.role_tenant_map m
WHERE m.role_name = CURRENT_ROLE()
AND m.tenant_id = tenant_id
);
For tenant-scoped tables that are large enough to need clustering, the right pattern is to cluster on the same column the row access policy filters on (tenant_id in this case). The policy then prunes to the tenant’s partitions even when the analyst writes a query that doesn’t mention tenant_id, because Snowflake can push the policy’s tenant_id = ... into the scan. Our clustering keys guide walks through when clustering is worth the credits.
If you suspect a row access policy is hurting query performance, the query profile is the diagnostic tool. Compare two runs of the same query — one as the privileged role with full visibility, one as the restricted role — and look at partitions_scanned. If the ratio is dramatically different, the policy is preventing pruning and you have a candidate for the fast-path rewrite above.
Auditing policies and coverage
The two views most worth knowing for governance are SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES and SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY.
POLICY_REFERENCES (docs) is the source of truth for “which policies are attached to what”. A query like the one below answers “do all tables in the customer_data schema have a row access policy attached?”:
SELECT
t.table_schema,
t.table_name,
p.policy_name,
p.policy_kind
FROM snowflake.account_usage.tables t
LEFT JOIN snowflake.account_usage.policy_references p
ON p.ref_entity_name = t.table_name
AND p.ref_entity_domain = 'TABLE'
WHERE t.table_schema = 'CUSTOMER_DATA'
AND t.deleted IS NULL
ORDER BY t.table_name;
Rows with policy_name IS NULL are tables the policy doesn’t cover. In a production deployment, this query is the thing you run nightly to catch policy drift — somebody created a new table in the sensitive schema and forgot to attach the standard policies.
ACCESS_HISTORY (docs) records every read of every column, including which masking policy fired and what it returned (the policy name, not the value). It is the audit log that compliance teams want when they ask “show me every analyst who read the ssn column in the last 30 days, and prove that the column was masked”:
SELECT
ah.query_start_time,
ah.user_name,
ah.role_name,
cma.policy_name AS masking_policy
FROM snowflake.account_usage.access_history ah,
LATERAL FLATTEN(input => ah.base_objects_accessed) bo,
LATERAL FLATTEN(input => bo.value:"columns") col,
LATERAL FLATTEN(input => col.value:"columnMaskingPolicies", outer => TRUE) cma
WHERE bo.value:"objectName"::STRING = 'CUSTOMER_DATA.CUSTOMERS'
AND col.value:"columnName"::STRING = 'SSN'
AND ah.query_start_time >= DATEADD(day, -30, CURRENT_TIMESTAMP())
ORDER BY ah.query_start_time DESC;
ACCESS_HISTORY lags by up to 3 hours, so it is not a real-time alerting source — but for the “prove that masking was in effect during the audit window” question, it is the canonical answer.
Deployment patterns
Policy SQL is part of the database schema and it belongs in version control. The two patterns that work in practice are declarative IaC with Terraform and SQL migrations.
The Snowflake Terraform provider exposes snowflake_masking_policy, snowflake_row_access_policy, and snowflake_table_column_masking_policy_application resources. The pattern that scales is to keep one Terraform module per schema with the table list, the policy definitions, and the attachments all expressed declaratively. A code review on the Terraform module is the right place to enforce “every new table in customer_data has a row access policy”. The downside is that Terraform’s drift detection on policy attachments can be noisy if anyone modifies policies in the UI, so you have to enforce “only Terraform changes policies” at the role-grant level (typically by giving the Terraform role exclusive ownership of the policies).
The SQL-migration pattern uses a tool like Flyway or dbt’s policy macros to keep CREATE OR REPLACE MASKING POLICY statements in versioned migration files. This is the right choice when your governance changes ship alongside model changes — the migration that adds a phone_number column to customers also attaches the phone_mask policy in the same PR.
For high-change-rate policy logic, a blue/green policy rollout pattern is worth knowing. Create the new policy version under a new name (email_mask_v2), ALTER TABLE ... MODIFY COLUMN ... SET MASKING POLICY email_mask_v2 to switch over, validate via POLICY_REFERENCES and ACCESS_HISTORY, and DROP MASKING POLICY email_mask only after a confidence window. This avoids the period during a single-statement update where the column is temporarily unmasked.
Common pitfalls
A few traps catch teams the first time they deploy policies at scale.
Self-joins and views can defeat row access policies if the policy expression depends on a column not in the row access predicate. A row access policy on orders that filters by tenant_id works correctly for SELECT * FROM orders. The same policy can produce surprising results when joined to a parent table — the policy still filters orders correctly, but the parent might leak the existence of filtered-out tenants through COUNT(*). The fix is to apply consistent row access policies to every table in a sensitive surface, not just the obviously-sensitive ones.
Masking policies don’t apply to SHARE consumers unless you specifically design for them. When you create a data share, the consumer’s queries run against your data with their own session context. CURRENT_ROLE() on the consumer side returns the consumer’s role, not yours, so a masking policy that only unmasks for 'ANALYTICS_FULL' will mask everything for the consumer regardless of what they need. Use INVOKER_SHARE() IS NOT NULL to detect share queries and apply a separate set of rules.
Cloning preserves policy attachments, but cloning across roles can change behavior. A zero-copy clone of customers into a sandbox carries the masking and row access policies with it. If the sandbox runs under a different role, the policies will produce different results — usually that’s correct (the sandbox role shouldn’t see PII either), but occasionally teams clone tables for testing and find that the masked values break their test fixtures. Make sandbox roles aware of the policy logic, not policy-free.
Policy changes invalidate the result cache. Snowflake’s result cache is per-role and per-query-text, but it is also invalidated when the underlying objects change — and a CREATE OR REPLACE MASKING POLICY counts as a change. After a policy update, expect the first few queries to miss the cache. This is rarely a problem in practice, but on dashboards that depend on cached results for sub-second response, you’ll see latency spike right after a policy deploy.
A row access policy that excludes everyone is silently a WHERE FALSE. A policy that returns FALSE for the active role doesn’t error — it returns zero rows, and SELECT COUNT(*) FROM orders returns 0. If you see a metric crater after a policy deploy, the first place to look is the row access policy logic for the role that’s reading the table.
Frequently asked questions
What is the difference between Snowflake dynamic data masking and external tokenization? Dynamic data masking (docs) operates on data that is already loaded into Snowflake in cleartext, transforming it at query time based on session context. External tokenization calls an external function to detokenize at query time, with the implication that the raw value never has to enter Snowflake — tokens are loaded instead, and only authorized queries detokenize. Use dynamic masking when “data exists in Snowflake but is hidden from some readers” is acceptable; use external tokenization when “raw data must not be at rest in Snowflake” is a hard requirement.
Can I apply more than one masking policy to a column?
No. A column can have at most one masking policy attached at a time. To encode multiple rules — “mask for analysts, redact for shares, hash for ML pipelines” — combine them in a single CASE expression in the policy. If the rules come from independently-owned policy modules, the standard pattern is to define each rule as a secure UDF and have one wrapper policy call them in order.
Do masking policies work on views?
Masking policies apply to columns of base tables. A view that selects a masked column inherits the masking — SELECT email FROM customer_summary_view runs the policy as if the query had hit the underlying table. You can also attach masking policies to view columns directly with ALTER VIEW ... MODIFY COLUMN ... SET MASKING POLICY, which is the right pattern when the column shape changes between the base table and the view (a CASE expression in the view, for example).
How do I test masking and row access policies?
Use USE ROLE to switch into each role in a development account and run representative queries. For automated testing, the pattern is to create a test role per persona, grant the policy-protected tables to each, and run a fixture of queries through each role with assertions on the result shape. The Snowflake Terraform provider supports parameterizing the role list so you can spin up test roles per CI run.
Can a row access policy reference another table?
Yes — this is the standard pattern for mapping tables. The policy expression can include subqueries, EXISTS clauses, and joins to other tables. The referenced tables must be readable by the role that attached the policy, not by the role running the query. Be aware that the policy evaluates the subquery for each query against the protected table, so the mapping table should be small and indexed (or cached in a small materialized view) for performance.
How do I find every column that has a masking policy attached?
Query SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES and filter on policy_kind = 'MASKING_POLICY'. The view returns one row per attachment, so you can join to SNOWFLAKE.ACCOUNT_USAGE.COLUMNS to enumerate which columns are not covered by any policy — that gap query is the right starting point for a governance audit.
Do masking and row access policies show up in SHOW GRANTS?
No. Policy attachments are not grants; they’re properties of the column or table they protect. SHOW GRANTS ON TABLE customers returns the role grants for the table; SELECT ... FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES returns the policy attachments. Audit scripts that only check SHOW GRANTS miss the policy layer entirely, which is one of the most common mistakes in DIY Snowflake compliance reviews.
Will a row access policy prevent partition pruning?
It depends on the policy expression. A simple predicate like region = 'EU' is pushed into the scan and prunes normally. A policy that joins to a mapping table is harder for the optimizer to push down and may force a full scan. The fast-path CASE pattern shown above keeps the privileged role’s queries on the pruning path. The query profile shows whether pruning happened — look at partitions_scanned / partitions_total and compare runs across roles.
Wrapping up
Masking and row access policies are the right answer to almost every “who can see what” question on a Snowflake account that holds production data. Masking handles column-level rules — partial reveal, role-based unmasking, format preservation. Row access handles row-level rules — multi-tenant filtering, region-based residency, classification-based exclusion. The two compose cleanly, both are evaluated at query time without copying or rewriting data, and both have first-class auditing through POLICY_REFERENCES and ACCESS_HISTORY.
The patterns that pay off fastest: encode the privileged role as a constant-time fast path so the policy doesn’t kill pruning, drive multi-tenant rules from a mapping table so onboarding a tenant is an INSERT rather than a SQL diff, store policy definitions in Terraform or migrations so they live in code review, and run the POLICY_REFERENCES coverage query as a nightly job to catch drift before an auditor does.
If you’re running Snowflake at the scale where this matters, the rest of our Snowflake series covers the levers that matter alongside governance: clustering keys for the partition pruning that row access policies depend on, the query profile for diagnosing policy performance, warehouse sizing for matching compute to workload, and zero-copy clones for the sandbox patterns that policy testing relies on. And when you need a SQL editor that respects your governance — every QueryPlane query runs as your own role, so policies fire exactly as they would in Snowsight — the QueryPlane Snowflake integration connects in a few minutes.