Snowflake Row-Level and Column-Level Security
Standard role-based access control grants or denies access to entire tables. But many real-world situations require finer control: a sales manager should see only their own region's orders, a doctor should see only their own patients, or a data analyst should see transaction amounts but not the payment card numbers. Snowflake provides two policy-based mechanisms to enforce these requirements: Row Access Policies for filtering rows and Dynamic Data Masking for obscuring column values. Both policies apply automatically at query time, transparently to the end user, without any changes to the underlying data.
The Problem These Features Solve
SCENARIO: GLOBAL SALES TABLE (10 million rows)
Without row-level security:
EMEA Manager queries orders:
SELECT * FROM ORDERS;
--> Returns all 10 million rows (US, EMEA, APAC combined)
--> Manager sees other regions' sensitive pricing
Without column-level masking:
Analyst queries customers:
SELECT customer_name, credit_card_number, email FROM CUSTOMERS;
--> Returns real credit card numbers
--> Analyst does not need this for their analysis
WITH ROW ACCESS POLICY + DATA MASKING:
EMEA Manager queries orders:
SELECT * FROM ORDERS;
--> Snowflake silently adds: WHERE region = 'EMEA'
--> Returns only 3.2 million EMEA rows — manager unaware of filter
Analyst queries customers:
SELECT customer_name, credit_card_number, email FROM CUSTOMERS;
--> credit_card_number shows as ****-****-****-4521 (masked)
--> No error, no blocked query — just protected data
Row Access Policies
A Row Access Policy is a SQL function that Snowflake evaluates for every row in a queried table. It returns TRUE (show this row) or FALSE (hide this row) based on the current session's context — typically the user's active role, their username, or values in a mapping table that defines what each role or user is allowed to see.
Simple Row Access Policy: Filter by Role
-- Step 1: Create the row access policy
-- This policy shows all rows to SYSADMIN, shows only the relevant region to others
CREATE ROW ACCESS POLICY rap_region_filter
AS (region VARCHAR) RETURNS BOOLEAN ->
CASE
WHEN CURRENT_ROLE() IN ('SYSADMIN', 'ACCOUNTADMIN')
THEN TRUE -- admins see all rows
WHEN CURRENT_ROLE() = 'EMEA_ANALYST'
THEN region = 'EMEA'
WHEN CURRENT_ROLE() = 'APAC_ANALYST'
THEN region = 'APAC'
WHEN CURRENT_ROLE() = 'US_ANALYST'
THEN region = 'US'
ELSE FALSE -- any other role sees no rows at all
END;
-- Step 2: Attach the policy to the table's region column
ALTER TABLE ORDERS
ADD ROW ACCESS POLICY rap_region_filter ON (region);
-- Now when anyone queries ORDERS:
-- EMEA_ANALYST sees: WHERE region = 'EMEA' applied silently
-- SYSADMIN sees: all rows
-- PUBLIC role sees: 0 rows
Advanced Row Access Policy: Using a Mapping Table
A mapping table approach is more flexible than hardcoding roles in the policy. You maintain the mapping in a regular Snowflake table, and the policy reads from it dynamically. Adding a new manager or changing a user's territory only requires updating the mapping table row — no policy change needed.
-- Step 1: Create the permission mapping table
CREATE TABLE SECURITY.REGION_ACCESS_MAP (
username VARCHAR(100),
region VARCHAR(50)
);
-- Populate with user-region assignments
INSERT INTO SECURITY.REGION_ACCESS_MAP VALUES
('alice.chen', 'EMEA'),
('alice.chen', 'US'), -- Alice can see both EMEA and US
('bob.kumar', 'APAC'),
('carol.jones', 'US');
-- Step 2: Create policy that looks up the mapping table
CREATE ROW ACCESS POLICY rap_user_region_map
AS (region VARCHAR) RETURNS BOOLEAN ->
CASE
WHEN CURRENT_ROLE() IN ('SYSADMIN', 'ACCOUNTADMIN')
THEN TRUE
ELSE EXISTS (
SELECT 1
FROM SECURITY.REGION_ACCESS_MAP m
WHERE m.username = CURRENT_USER()
AND m.region = region
)
END;
-- Step 3: Attach policy
ALTER TABLE ORDERS
ADD ROW ACCESS POLICY rap_user_region_map ON (region);
-- Now: add/remove territory access by editing REGION_ACCESS_MAP only
-- Policy automatically reflects the change for all future queries
Managing Row Access Policies
-- View all row access policies in the account SHOW ROW ACCESS POLICIES; -- Describe a policy (shows its SQL definition) DESC ROW ACCESS POLICY rap_region_filter; -- Show which tables a policy is attached to SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES WHERE POLICY_NAME = 'RAP_REGION_FILTER'; -- Remove a policy from a table ALTER TABLE ORDERS DROP ROW ACCESS POLICY rap_region_filter; -- Replace one policy with another on a table ALTER TABLE ORDERS DROP ROW ACCESS POLICY rap_region_filter, ADD ROW ACCESS POLICY rap_user_region_map ON (region); -- Drop a policy entirely (must be detached from all tables first) DROP ROW ACCESS POLICY rap_region_filter;
Dynamic Data Masking
A masking policy defines how Snowflake transforms column values before displaying them to a user. The transformation depends on the querying user's role. Full administrators see the real value. Lower-privilege users see a masked version — partially obscured, fully replaced with a constant, or hashed to a non-reversible token.
Creating Masking Policies
-- Policy 1: Full mask for credit card numbers
-- Admins see: 4111-1234-5678-9012
-- Analysts see: ****-****-****-9012 (last 4 only)
-- Others see: ****-****-****-****
CREATE MASKING POLICY mask_credit_card
AS (val VARCHAR) RETURNS VARCHAR ->
CASE
WHEN CURRENT_ROLE() IN ('SYSADMIN', 'ACCOUNTADMIN')
THEN val -- real value
WHEN CURRENT_ROLE() = 'SENIOR_ANALYST'
THEN CONCAT('****-****-****-', RIGHT(val, 4)) -- last 4 digits only
ELSE '****-****-****-****' -- fully masked
END;
-- Policy 2: Email masking (show domain but hide local part)
-- alice.chen@company.com --> a****@company.com
CREATE MASKING POLICY mask_email
AS (val VARCHAR) RETURNS VARCHAR ->
CASE
WHEN CURRENT_ROLE() IN ('SYSADMIN', 'ACCOUNTADMIN')
THEN val
ELSE CONCAT(LEFT(val, 1), '****@',
SPLIT_PART(val, '@', 2))
END;
-- Policy 3: Salary masking (show range instead of exact value)
-- 85000 --> 80001-90000
CREATE MASKING POLICY mask_salary
AS (val NUMBER) RETURNS VARCHAR ->
CASE
WHEN CURRENT_ROLE() IN ('SYSADMIN', 'HR_ADMIN')
THEN val::VARCHAR
WHEN CURRENT_ROLE() = 'HR_ANALYST'
THEN CONCAT(
(FLOOR(val / 10000) * 10000 + 1)::VARCHAR, '-',
(CEIL(val / 10000) * 10000)::VARCHAR
)
ELSE 'CONFIDENTIAL'
END;
-- Policy 4: SHA-256 hash for pseudonymisation (reproducible but non-reversible)
CREATE MASKING POLICY mask_pii_hash
AS (val VARCHAR) RETURNS VARCHAR ->
CASE
WHEN CURRENT_ROLE() IN ('SYSADMIN', 'ACCOUNTADMIN')
THEN val
ELSE SHA2(val, 256) -- same input always produces same hash (join-compatible)
END;
Attaching Masking Policies to Columns
-- Attach a masking policy to a specific column ALTER TABLE CUSTOMERS MODIFY COLUMN credit_card_number SET MASKING POLICY mask_credit_card; ALTER TABLE CUSTOMERS MODIFY COLUMN email SET MASKING POLICY mask_email; ALTER TABLE EMPLOYEES MODIFY COLUMN base_salary SET MASKING POLICY mask_salary; -- Verify masking policy is active on a table DESCRIBE TABLE CUSTOMERS; -- Output shows the masking policy name next to each protected column -- Remove a masking policy from a column ALTER TABLE CUSTOMERS MODIFY COLUMN credit_card_number UNSET MASKING POLICY;
How Masking Looks at Query Time
TABLE: CUSTOMERS (actual stored data) customer_id | name | credit_card_number | email ------------|---------------|---------------------|------------------- 1001 | Alice Chen | 4111-1234-5678-9012 | alice@company.com 1002 | Bob Kumar | 5500-4321-8765-1098 | bob@company.com QUERY: SELECT customer_id, name, credit_card_number, email FROM CUSTOMERS; Result seen by SYSADMIN: 1001 | Alice Chen | 4111-1234-5678-9012 | alice@company.com 1002 | Bob Kumar | 5500-4321-8765-1098 | bob@company.com Result seen by SENIOR_ANALYST: 1001 | Alice Chen | ****-****-****-9012 | a****@company.com 1002 | Bob Kumar | ****-****-****-1098 | b****@company.com Result seen by ANALYST: 1001 | Alice Chen | ****-****-****-**** | a****@company.com 1002 | Bob Kumar | ****-****-****-**** | b****@company.com No changes to underlying data — only the displayed value differs per role.
Column-Level Security with Object Tags
Snowflake Tags let you categorise sensitive columns and then apply masking policies to all tagged columns at once. This is more scalable than manually attaching policies to each column across many tables.
-- Create tags to mark sensitive data categories CREATE TAG pii_type ALLOWED_VALUES 'EMAIL', 'PHONE', 'SSN', 'CREDIT_CARD', 'NAME'; CREATE TAG data_sensitivity ALLOWED_VALUES 'PUBLIC', 'INTERNAL', 'CONFIDENTIAL', 'RESTRICTED'; -- Apply tags to columns ALTER TABLE CUSTOMERS MODIFY COLUMN email SET TAG pii_type = 'EMAIL', data_sensitivity = 'CONFIDENTIAL'; ALTER TABLE CUSTOMERS MODIFY COLUMN credit_card_number SET TAG pii_type = 'CREDIT_CARD', data_sensitivity = 'RESTRICTED'; -- Attach a masking policy to a tag (policy applies to all tagged columns) ALTER TAG pii_type SET MASKING POLICY mask_email USING (pii_type) FOR (VARCHAR); -- Find all columns with a specific tag (for data cataloguing) SELECT * FROM TABLE(SNOWFLAKE.ACCOUNT_USAGE.TAG_REFERENCES( TAG_NAME => 'SNOWFLAKE_COURSE.SECURITY.PII_TYPE', TAG_DOMAIN => 'COLUMN' ));
Combining Row Access Policies and Column Masking
Row access policies and masking policies work together on the same table independently. A user might see only their region's rows (row policy) and within those rows, see masked credit card numbers (column masking). Snowflake applies both policies transparently in a single query.
TABLE: ORDERS with both policies active EMEA_ANALYST role queries: SELECT * FROM ORDERS; Row access policy: Silently adds WHERE region = 'EMEA' Masking policy: Replaces credit_card_number with ****-****-****-**** Result: Only EMEA rows, with masked payment details SYSADMIN role queries: SELECT * FROM ORDERS; Row access policy: Returns TRUE for all rows (no filter) Masking policy: Returns TRUE values (no masking) Result: All rows, all real values
Key Points
- Row Access Policies filter rows at query time based on the querying role or user — the end user sees no error, just fewer rows
- Use a mapping table with row access policies for flexible, database-driven permission management instead of hardcoding roles in policy SQL
- Dynamic Data Masking transforms column values before displaying them — real data in storage is untouched
- Four common masking patterns: full mask, partial mask (show last N digits), hash (pseudonymisation), and range replacement
- Tags enable bulk policy application — tag a column as PII_TYPE=CREDIT_CARD and the masking policy attaches automatically to all matching columns
- Row access and column masking policies apply simultaneously on the same table — they are independent and composable
- Both features require Enterprise edition or above — they are not available on the Standard edition
