Row access policies and masking policies are Snowflake's data-level access controls. A masking policy rewrites the value of a single column at query time based on context (role, session attributes, business hours); a row access policy appends a predicate to the query plan so unauthorized rows never appear in the result set. Both are evaluated by the optimizer, both run inline with the query, and both let you keep one physical table serving every persona instead of fragmenting into per-role views.
The diagram shows where row and column controls plug into the query lifecycle. Row access runs before masking — there is no point masking columns of rows the caller will never see.
┌──────────────────────────────────────────────────────────────────────┐ │ QUERY EVALUATION PIPELINE │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 1. User issues SELECT statement │ │ │ └────────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 2. Snowflake resolves CURRENT_ROLE / IS_ROLE_IN_SESSION │ │ │ └────────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 3. Row Access Policy evaluated → predicate appended to WHERE │ │ │ └────────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 4. Masking Policies evaluated → CASE wrapped around column │ │ │ └────────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 5. Filtered, masked result set returned to client │ │ │ └────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘
A masking policy is a UDF whose first argument is the column value being masked. It must return the same data type. Snowflake injects the policy as a CASE expression around every reference to the column.
USE ROLE SECURITYADMIN;
CREATE OR REPLACE MASKING POLICY pii_email_mask AS
(val STRING) RETURNS STRING ->
CASE
WHEN CURRENT_ROLE() IN ('PII_READER', 'COMPLIANCE')
THEN val
WHEN CURRENT_ROLE() = 'ANALYST'
THEN REGEXP_REPLACE(val, '.+@', '****@')
ELSE '***REDACTED***'
END;
ALTER TABLE customers
MODIFY COLUMN email
SET MASKING POLICY pii_email_mask;
IS_ROLE_IN_SESSION is preferred over CURRENT_ROLE when secondary roles are in play — it returns true if the role appears anywhere in the active role set, which is what most enterprise identity stacks want.
CREATE OR REPLACE MASKING POLICY ssn_mask AS
(val STRING) RETURNS STRING ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
WHEN IS_ROLE_IN_SESSION('FRAUD_INVESTIGATOR') THEN 'XXX-XX-' || RIGHT(val, 4)
ELSE SHA2(val, 256) -- deterministic pseudonym for joins
END;
-- Conditional masking: peek at another column on the same row
CREATE OR REPLACE MASKING POLICY salary_mask AS
(val NUMBER, dept STRING) RETURNS NUMBER ->
CASE
WHEN CURRENT_ROLE() = 'HR_ADMIN' THEN val
WHEN CURRENT_ROLE() = 'MANAGER' AND dept = 'ENG' THEN val
ELSE NULL
END;
ALTER TABLE employees
MODIFY COLUMN salary
SET MASKING POLICY salary_mask USING (salary, department);
A row access policy is a UDF that returns boolean for each row. The optimizer pushes this predicate into the table scan, so unauthorized rows are never read off disk.
CREATE OR REPLACE ROW ACCESS POLICY region_access AS
(region STRING) RETURNS BOOLEAN ->
CASE
WHEN IS_ROLE_IN_SESSION('GLOBAL_READER') THEN TRUE
WHEN IS_ROLE_IN_SESSION('EMEA_READER') THEN region = 'EMEA'
WHEN IS_ROLE_IN_SESSION('AMER_READER') THEN region = 'AMER'
WHEN IS_ROLE_IN_SESSION('APAC_READER') THEN region = 'APAC'
ELSE FALSE
END;
ALTER TABLE orders
ADD ROW ACCESS POLICY region_access ON (shipping_region);
Hard-coding role-to-region in the policy doesn't scale. Use a mapping table: a small lookup of (role, allowed_value) pairs the policy joins against. Updating who sees what becomes a SQL update, not a DDL change.
CREATE OR REPLACE TABLE governance.role_region_map (
role_name STRING,
region STRING
);
INSERT INTO governance.role_region_map VALUES
('EMEA_READER', 'EMEA'),
('AMER_READER', 'AMER'),
('APAC_READER', 'APAC'),
('GLOBAL_READER', 'EMEA'),
('GLOBAL_READER', 'AMER'),
('GLOBAL_READER', 'APAC');
CREATE OR REPLACE ROW ACCESS POLICY region_via_map AS
(region STRING) RETURNS BOOLEAN ->
EXISTS (
SELECT 1
FROM governance.role_region_map m
WHERE m.region = region
AND IS_ROLE_IN_SESSION(m.role_name)
);
ALTER TABLE orders
DROP ROW ACCESS POLICY region_access;
ALTER TABLE orders
ADD ROW ACCESS POLICY region_via_map ON (shipping_region);
A single physical orders table now serves every regional persona. Adding a new region is one INSERT into the mapping table. The policy itself never changes.
Policies attached to a base table propagate through views and secure data sharing. A consumer of a Snowflake share sees the data filtered by the producer's policies under the consumer's role context — the value of CURRENT_ROLE() in the policy resolves on the consumer side.
-- Policies follow the column through views
CREATE SECURE VIEW vw_customer_summary AS
SELECT customer_id, email, region, total_orders
FROM customers;
-- email is still masked, region is still filtered, no extra DDL needed
-- Apply at the view level if the column is computed only in the view
ALTER VIEW vw_customer_summary
MODIFY COLUMN email
SET MASKING POLICY pii_email_mask;
Two ACCOUNT_USAGE views are the source of truth for "is this column actually protected?".
-- Every column with a masking policy attached
SELECT ref_database_name, ref_schema_name, ref_entity_name,
ref_column_name, policy_name, policy_kind
FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES
WHERE policy_kind = 'MASKING_POLICY';
-- Every table with a row access policy
SELECT ref_database_name, ref_schema_name, ref_entity_name,
policy_name, policy_kind
FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES
WHERE policy_kind = 'ROW_ACCESS_POLICY';
-- Find PII-tagged columns that have NO masking policy attached
SELECT t.object_database, t.object_schema, t.object_name, t.column_name
FROM SNOWFLAKE.ACCOUNT_USAGE.TAG_REFERENCES t
LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES p
ON t.object_database = p.ref_database_name
AND t.object_schema = p.ref_schema_name
AND t.object_name = p.ref_entity_name
AND t.column_name = p.ref_column_name
WHERE t.tag_name = 'PII'
AND p.policy_name IS NULL;
CURRENT_ROLE() with secondary roles. If your org enables secondary roles, a user might appear under PUBLIC as primary while having PII_READER as secondary. Use IS_ROLE_IN_SESSION to be explicit.