Data Governance in Snowflake: Row-Level Security, Masking Policies, and Access Controls
Last updated: December 2025
Quick answer: Snowflake's governance model centers on RBAC with role hierarchies, row access policies for row-level security, dynamic data masking policies for column-level protection, secure views for hiding query logic, and object tagging for automated classification. Start with a clean role hierarchy (functional roles separate from data access roles), add row access policies and masking where compliance requires it, and use ACCESS_HISTORY for auditing. Gotcha: masking policies can't reference other tables directly, and row access policies add overhead to every query - test with production-scale data before rolling out.
Introduction
Snowflake ships with a solid set of governance features, but most teams don't use them effectively. They'll create a handful of roles, slap a masking policy on the SSN column, and call it governed. Then 6 months later, an auditor asks who accessed customer PII last quarter and nobody can answer the question. This guide covers the practical implementation of Snowflake's governance features - the parts that matter for real compliance requirements, with the syntax and gotchas you'll hit along the way.
Snowflake's RBAC Model: Building a Role Hierarchy That Works
Snowflake uses role-based access control as its foundation. Every privilege - SELECT on a table, USAGE on a warehouse, CREATE TABLE in a schema - gets granted to a role, and roles get granted to users (or other roles). The mistake most teams make is creating one role per person or one role per team. Neither scales.
The pattern that works: separate functional roles from data access roles. Functional roles define what someone can do (run queries, create views, manage pipelines). Data access roles define what data someone can see.
-- Functional roles CREATE ROLE analyst; CREATE ROLE data_engineer; CREATE ROLE data_admin; -- Data access roles CREATE ROLE pii_reader; CREATE ROLE finance_data; CREATE ROLE marketing_data; -- Compose: an analyst who needs finance data GRANT ROLE analyst TO USER jane; GRANT ROLE finance_data TO USER jane; -- Role hierarchy: data_admin inherits from data_engineer GRANT ROLE data_engineer TO ROLE data_admin;
This composable approach means you don't need to create a finance_analyst AND a marketing_analyst AND a finance_engineer role. You mix and match. When a new department comes along, you create one new data access role instead of N new combined roles.
Row-Level Security with Row Access Policies
Row access policies let you filter which rows a user sees based on their role or identity. The policy is a SQL expression that returns a boolean - if it returns TRUE for a row, the user sees it. If FALSE, the row is invisible.
-- Create a row access policy for multi-tenant data
CREATE OR REPLACE ROW ACCESS POLICY department_filter
AS (department_id VARCHAR) RETURNS BOOLEAN ->
CURRENT_ROLE() = 'DATA_ADMIN'
OR department_id = CURRENT_SESSION()::VARIANT:department_id::VARCHAR
OR IS_ROLE_IN_SESSION('ALL_DEPARTMENTS');
-- Apply it to the table
ALTER TABLE sales.orders
ADD ROW ACCESS POLICY department_filter ON (department_id);
The policy above lets DATA_ADMIN see everything, users with the ALL_DEPARTMENTS role see everything, and everyone else only sees rows where department_id matches their session variable. This is the multi-tenant pattern - each department sees only their own data from the same table.
Performance gotcha: Row access policies add a predicate to every query against the protected table. Snowflake evaluates the policy condition for every row. On tables with 100M+ rows, this can add 10-30% overhead depending on the policy complexity. If your policy column aligns with the table's clustering key, the overhead is minimal because Snowflake can prune micro-partitions. If it doesn't, you're scanning more data. Test with representative workloads before deploying to production.
Dynamic Data Masking
Masking policies let you hide or partially reveal column values based on the querying user's role. Unlike row access policies (which filter rows), masking policies transform column values in place.
-- Full masking: non-privileged roles see '***'
CREATE OR REPLACE MASKING POLICY mask_ssn
AS (val STRING) RETURNS STRING ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
ELSE '***-**-****'
END;
-- Partial masking: show last 4 digits of phone
CREATE OR REPLACE MASKING POLICY mask_phone
AS (val STRING) RETURNS STRING ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
ELSE CONCAT('***-***-', RIGHT(val, 4))
END;
-- Apply to columns
ALTER TABLE customers.profiles
MODIFY COLUMN ssn SET MASKING POLICY mask_ssn;
ALTER TABLE customers.profiles
MODIFY COLUMN phone SET MASKING POLICY mask_phone;
Critical gotcha: Masking policies can't reference other tables directly. You can't write a policy that says "look up this user in an access control table and decide whether to mask." You're limited to context functions: CURRENT_ROLE(), CURRENT_USER(), IS_ROLE_IN_SESSION(), and session variables. If you need lookup-based masking, create a secure UDF that does the lookup and call it from your masking policy - but be aware this adds query latency.
Secure Views vs. Regular Views
Regular views in Snowflake expose the view definition to anyone with SELECT privilege. That's a problem if the view logic itself reveals something about the underlying data structure you don't want users to know. Secure views solve this in two ways: they hide the view definition, and they prevent the optimizer from pushing user predicates into the view query.
CREATE OR REPLACE SECURE VIEW analytics.customer_summary AS SELECT customer_id, region, total_orders, lifetime_value FROM raw.customers WHERE is_active = TRUE;
With a regular view, a clever user could add WHERE is_active = FALSE and potentially infer information about inactive customers through error messages or timing differences. With a secure view, the optimizer won't push that predicate down. The tradeoff: secure views can be 10-20% slower because the optimizer has fewer options for query planning. Use secure views for anything involving sensitive data or row filtering logic. Use regular views for everything else.
Object Tagging for Data Classification
Snowflake's object tagging lets you attach metadata to databases, schemas, tables, and columns. This is how you classify data (PII, PHI, financial, public) without maintaining a separate data catalog.
-- Create tag
CREATE OR REPLACE TAG governance.sensitivity
ALLOWED_VALUES 'PII', 'PHI', 'FINANCIAL', 'INTERNAL', 'PUBLIC';
-- Apply tags
ALTER TABLE customers.profiles
MODIFY COLUMN email SET TAG governance.sensitivity = 'PII';
ALTER TABLE customers.profiles
MODIFY COLUMN ssn SET TAG governance.sensitivity = 'PII';
-- Auto-classify using SYSTEM$CLASSIFY (Enterprise+)
SELECT SYSTEM$CLASSIFY('customers.profiles', {'auto_tag': true});
SYSTEM$CLASSIFY uses pattern matching and sampling to automatically detect sensitive columns (emails, phone numbers, SSNs) and tag them. It's not perfect - it misses custom identifiers and sometimes flags non-sensitive columns that happen to look like phone numbers - but it's a good starting point that you then refine manually.
Access History and Auditing
Snowflake's ACCESS_HISTORY view in the ACCOUNT_USAGE schema tracks which columns were accessed by which queries. Combined with QUERY_HISTORY, this gives you a complete audit trail.
-- Who accessed PII columns in the last 30 days?
SELECT
user_name,
query_id,
query_start_time,
direct_objects_accessed
FROM snowflake.account_usage.access_history
WHERE query_start_time > DATEADD('day', -30, CURRENT_TIMESTAMP())
AND ARRAY_CONTAINS('customers.profiles.ssn'::VARIANT,
ARRAY_AGG(f.value:columnName::STRING))
ORDER BY query_start_time DESC;
ACCESS_HISTORY retains data for 365 days. For longer retention, export it to a separate table on a schedule. Pair this with your broader governance and cost optimization framework to track not just who accessed data, but what it cost to serve those queries.
Network Policies for IP Whitelisting
Network policies restrict which IP addresses can connect to your Snowflake account. This is a blunt but effective control - even if someone has valid credentials, they can't connect from an unauthorized network.
CREATE NETWORK POLICY office_only
ALLOWED_IP_LIST = ('203.0.113.0/24', '198.51.100.0/24')
BLOCKED_IP_LIST = ('203.0.113.99');
-- Apply account-wide
ALTER ACCOUNT SET NETWORK_POLICY = office_only;
-- Or apply to specific users
ALTER USER contractor_jane SET NETWORK_POLICY = office_only;
You can apply network policies at the account level or per-user. Per-user policies override account policies. For organizations using Azure PrivateLink or AWS PrivateLink, network policies work alongside private connectivity to provide defense in depth.
Putting It Together: Multi-Tenant Data Access Model
Here's how these features compose for a real scenario - a multi-tenant analytics platform where each department sees only their data, PII is masked for non-privileged users, and everything is auditable:
- Role hierarchy: Functional roles (analyst, engineer) composed with data access roles (dept_sales, dept_marketing, pii_reader)
- Row access policy: Applied to fact tables filtering on department_id using session variables
- Masking policies: Applied to PII columns (email, phone, SSN) using IS_ROLE_IN_SESSION('PII_READER')
- Secure views: Built on top of raw tables with additional business logic, hiding the join conditions and filter criteria from end users
- Object tags: All PII columns tagged with sensitivity = 'PII' for easy discovery and audit
- Network policy: Corporate VPN IPs whitelisted at the account level
- Audit: Weekly ACCESS_HISTORY report showing who queried PII-tagged columns
Key Takeaways
- Separate functional roles from data access roles - it's the only RBAC pattern that scales without creating hundreds of combined roles
- Row access policies work well but add query overhead - align your policy columns with clustering keys and test performance at scale
- Masking policies can't reference other tables - use context functions or secure UDFs for lookup-based masking
- Use secure views for anything involving sensitive data; accept the 10-20% performance tradeoff
- Tag everything with
SYSTEM$CLASSIFYas a starting point, then refine manually - Set up ACCESS_HISTORY exports early - you'll need them the first time an auditor asks who accessed what
CelestInfo Engineering Team
Data engineering and cloud consulting. We build governance frameworks, migrate warehouses, and make data platforms that pass audits without surprises. About us
Related Articles
Frequently Asked Questions
Can Snowflake masking policies reference other tables?
No, masking policies can't directly reference other tables. You need to use context functions like CURRENT_ROLE(), CURRENT_USER(), or IS_ROLE_IN_SESSION() to make masking decisions. If you need lookup-based masking, create a secure UDF that performs the lookup and call it from your masking policy.
Do row access policies affect query performance in Snowflake?
Yes. Row access policies add a predicate to every query against the protected table, which means Snowflake evaluates the policy condition for every row. For large tables (100M+ rows), this can add measurable overhead. Always test with representative workloads and consider whether clustering keys align with the columns used in your row access policy.
What is the difference between secure views and regular views in Snowflake?
Secure views hide the view definition (SQL query) from users who don't own the view, and the Snowflake query optimizer won't push predicates from user queries into the view definition. This prevents users from inferring underlying data through query plan analysis. Regular views expose the definition and allow predicate pushdown, which is faster but less secure.
How do I audit who accessed what data in Snowflake?
Snowflake's ACCESS_HISTORY view in the ACCOUNT_USAGE schema tracks which columns were accessed by which queries. Combined with QUERY_HISTORY, you can build a complete audit trail of who queried what data, when, and how. ACCESS_HISTORY has a 365-day retention period.
