Snowflake Dynamic Data Masking & Row Access Policies: A Production Guide

Written by

in

The governance review lands on a Wednesday. Your company needs to prove that analysts in one region cannot see customer PII from another, that customer emails are masked for anyone below the data steward tier, and that — this one is new — AI agents querying your warehouse cannot extract raw PII even when the agent is running under a privileged role. You have two weeks.

Snowflake has the tools for all three. Dynamic Data Masking controls what value a user sees in a column. Row Access Policies control which rows they see at all. And since 2026, a new context function — IS_AGENT_ACTIVATED — lets your masking policies detect when a Cortex AI agent is making the query and mask accordingly, regardless of what role the agent is running under. Together, these three form a governance stack that scales from a single sensitive column to millions of rows across hundreds of tables — if you configure them correctly. If you don’t, they are bypassable in ways that are easy to miss until an auditor asks.

This article covers the mechanics, the production-scale patterns (tag-based masking in particular), and the gotchas that catch teams the first time.

TL;DR

  • Dynamic Data Masking (DDM) is column-level: it controls the value returned for a column based on the querying role. Row Access Policies (RAP) are row-level: they filter which rows are visible. Most enterprise setups need both.
  • Both features require Enterprise Edition or higher. Standard Edition provides basic RBAC but not policy-driven masking or row filtering.
  • A column can have only one masking policy attached at a time. Input and output data types must match exactly — you cannot mask a TIMESTAMP column and return a STRING.
  • Tag-based masking is the scalability unlock: attach a masking policy to a tag at the schema level and every new table with a matching column data type is automatically protected — no per-table ALTER COLUMN SET MASKING POLICY needed.
  • IS_AGENT_ACTIVATED is a new 2026 context function you can embed in masking policy CASE expressions to mask data from AI agents even when the agent’s role would otherwise allow plain-text access. This matters for MCP server setups and Cortex Agents.
  • POLICY_CONTEXT is your testing function — use it to simulate query execution as a specific role without switching sessions, so you can verify masking behavior before applying policies to production columns.
  • Row Access Policies using a mapping table must not use the protected table itself as the mapping table — Snowflake will reject it. External tables are also unsupported as mapping tables.

DDM vs Row Access Policies: The Conceptual Split

The confusion between these two features is understandable — both “restrict what users see” — but they operate at completely different layers. Understanding the split is the prerequisite for getting both right.

📷 ddm masks column values; rap filters rows — store data untouched in both cases, policy logic runs entirely at query time

The bottom of that diagram carries the most important fact: Snowflake never modifies or encrypts the stored data. Both policies evaluate entirely at query runtime. A row that is filtered by a Row Access Policy still exists in storage. A value masked to **** is still the original string on disk. This means Time Travel, data sharing, and cloning still work normally — but it also means a user with direct access to the underlying storage (unlikely, but worth knowing) would see plain text.

DimensionDynamic Data MaskingRow Access Policy
ControlsColumn value (what is shown)Row visibility (which rows exist)
AttachmentOne policy per columnOne policy per table or view
Data typesInput type must match output typeAlways returns BOOLEAN (include/exclude)
Performance impactMinimal — evaluates per column in resultCOUNT(*) triggers full scan without clustering
Mapping tableNot applicableRequired for role-to-region entitlements
Works with streamsYesYes — RAP applied when stream reads source table
Works with data sharingYesYes
Works with materialized viewsNot directly — apply to base table insteadYes
Edition requiredEnterprise+Enterprise+

Setting Up Dynamic Data Masking

The setup pattern is always the same three steps: create a policy, grant privileges, apply it to a column. The complexity is in the policy logic itself — getting the CASE conditions right so the right roles see the right data.

Creating and applying a basic masking policy

-- Step 1: Create a dedicated masking admin role (security officer)
CREATE ROLE masking_admin;
GRANT CREATE MASKING POLICY ON SCHEMA prod_db.sensitive_schema TO ROLE masking_admin;
GRANT APPLY MASKING POLICY ON ACCOUNT TO ROLE masking_admin;

-- Step 2: Create the masking policy (runs as masking_admin)
CREATE OR REPLACE MASKING POLICY prod_db.sensitive_schema.email_mask
  AS (val STRING) RETURNS STRING ->
  CASE
    -- AI agents: always mask regardless of role
    WHEN SYS_CONTEXT('SNOWFLAKE$CURRENT', 'IS_AGENT_ACTIVATED')::BOOLEAN = TRUE
      THEN REGEXP_REPLACE(val, '(^[^@]{2}).*(@.*$)', '\\1***\\2')
    -- Data stewards see plain text
    WHEN CURRENT_ROLE() IN ('DATA_STEWARD', 'PRIVACY_OFFICER')
      THEN val
    -- Analysts see partially masked email
    WHEN CURRENT_ROLE() = 'ANALYST'
      THEN REGEXP_REPLACE(val, '(^[^@]{2}).*(@.*$)', '\\1***\\2')
    -- Everyone else sees fully redacted
    ELSE '****@****.***'
  END;

-- Step 3: Apply to the column
ALTER TABLE prod_db.customers_schema.customers
  MODIFY COLUMN email
  SET MASKING POLICY prod_db.sensitive_schema.email_mask;

A few things to notice here. First, the IS_AGENT_ACTIVATED check comes before the role checks — that ordering matters. If a Cortex Agent is running under DATA_STEWARD, the role check would allow plain text, but the agent check intercepts it first. Second, the function signature declares val STRING and returns STRING — if you try to apply this policy to a TIMESTAMP column, Snowflake rejects it with a type mismatch error. One policy, one data type.

Testing with POLICY_CONTEXT before applying

Applying a masking policy to a production column and then testing it is backwards. Use POLICY_CONTEXT to simulate the query as a specific role without touching the policy attachment:

-- Simulate what ANALYST role would see on the email column
SELECT POLICY_CONTEXT(
  'SELECT email FROM prod_db.customers_schema.customers LIMIT 5',
  OBJECT_CONSTRUCT('role', 'ANALYST')
);

-- Simulate what DATA_STEWARD sees
SELECT POLICY_CONTEXT(
  'SELECT email FROM prod_db.customers_schema.customers LIMIT 5',
  OBJECT_CONSTRUCT('role', 'DATA_STEWARD')
);

This is the function the official column-level security docs recommend for pre-deployment validation. It also works for Row Access Policies and can simulate both simultaneously when a column is covered by both policy types.

Tag-Based Masking: The Scalability Pattern

Manual column-by-column masking breaks at scale. A schema with 200 tables, each with 3–5 PII columns, means 600–1000 individual ALTER COLUMN SET MASKING POLICY commands — and every new table added to that schema needs the same treatment. Miss one during a 2 AM data load and you’ve exposed PII until the next audit cycle.

Tag-based masking solves this by inverting the relationship: instead of attaching a policy to a column, you attach the policy to a tag, then tag the schema. Every column in every table in that schema with a matching data type gets automatically protected — including tables added in the future.

📷 tag the schema once — every future table with a string column picks up the mask automatically, no manual step needed

-- Create the tag
CREATE OR REPLACE TAG prod_db.governance_schema.pii_email
  COMMENT = 'Marks columns containing raw email addresses';

-- Bind the masking policy to the tag
ALTER TAG prod_db.governance_schema.pii_email
  SET MASKING POLICY prod_db.sensitive_schema.email_mask;

-- Apply the tag at schema level (protects all existing + future tables)
ALTER SCHEMA prod_db.customers_schema
  SET TAG prod_db.governance_schema.pii_email = 'true';

-- Verify which columns are now protected
SELECT *
FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES
WHERE POLICY_DB = 'PROD_DB'
  AND POLICY_NAME = 'EMAIL_MASK'
ORDER BY REF_COLUMN_NAME;

One important limitation to know upfront: a column can be protected by a directly-assigned masking policy and a tag-based masking policy simultaneously — but if both exist, the directly-assigned policy takes precedence. That’s actually useful for exceptions: tag the schema for broad protection, then override specific columns with stricter or looser policies applied directly.

IS_AGENT_ACTIVATED: Governing AI Agent Access

This is the most operationally important addition to the masking feature set in 2026, and it lands squarely in the overlap between data governance and the AI agent infrastructure your team is probably already building.

The problem it solves: when a Cortex Agent — or any AI agent connecting through an MCP server — runs a query, it does so under a Snowflake role. If that role has ANALYST-level access to a table, the agent reads the same data an analyst would. But analysts are humans who can be trained not to copy PII out of their query results. An agent processing thousands of rows and writing results to an output table, or sending them to an external API, is a different risk profile entirely.

📷 is_agent_activated fires before the role check — a privileged agent gets the same mask as an unprivileged one

IS_AGENT_ACTIVATED is read via SYS_CONTEXT in the masking policy body. When Snowflake detects that the current session is an AI agent context — Cortex Agent, or a session initiated through the Cortex Agent API — this returns TRUE. The masking policy evaluates it before any role check, so the agent cannot bypass it by inheriting a privileged role.

-- Masking policy that distinguishes human analysts from AI agents
-- even when both share the same Snowflake role
CREATE OR REPLACE MASKING POLICY prod_db.sensitive_schema.pii_phone_mask
  AS (val STRING) RETURNS STRING ->
  CASE
    -- Block AI agents regardless of their active role
    WHEN SYS_CONTEXT('SNOWFLAKE$CURRENT', 'IS_AGENT_ACTIVATED')::BOOLEAN = TRUE
      THEN '***-***-****'
    -- Privacy officers see the real number
    WHEN CURRENT_ROLE() IN ('PRIVACY_OFFICER', 'DATA_STEWARD')
      THEN val
    -- Analysts see last 4 digits only
    WHEN CURRENT_ROLE() = 'ANALYST'
      THEN CONCAT('***-***-', RIGHT(val, 4))
    ELSE '***-***-****'
  END;

If you’re running Cortex AI at scale, this pattern should be in your governance playbook. The risk is real: an agent with a monitoring query that runs on millions of rows, combined with an output path to a downstream system or an email notification, can exfiltrate PII that your masking policies were designed to protect — simply because the agent’s role was granted for legitimate operational reasons, not for raw data access.

Row Access Policies: Controlling Visible Rows

A Row Access Policy returns a boolean expression that Snowflake evaluates per row. Rows where the expression returns TRUE are visible; rows where it returns FALSE are hidden as if they don’t exist. The standard pattern uses a mapping table that maps roles (or users) to the regions or segments they’re allowed to see.

-- Mapping table: which roles can see which regions
CREATE TABLE prod_db.governance_schema.region_access_map (
  role_name   VARCHAR,
  region_code VARCHAR
);

INSERT INTO prod_db.governance_schema.region_access_map VALUES
  ('EMEA_ANALYST',  'EMEA'),
  ('APAC_ANALYST',  'APAC'),
  ('US_ANALYST',    'US'),
  ('GLOBAL_ADMIN',  'EMEA'),
  ('GLOBAL_ADMIN',  'APAC'),
  ('GLOBAL_ADMIN',  'US');

-- Row Access Policy using the mapping table
CREATE OR REPLACE ROW ACCESS POLICY prod_db.governance_schema.region_row_policy
  AS (region_col VARCHAR) RETURNS BOOLEAN ->
  EXISTS (
    SELECT 1
    FROM prod_db.governance_schema.region_access_map
    WHERE role_name   = CURRENT_ROLE()
      AND region_code = region_col
  );

-- Apply to the orders table
ALTER TABLE prod_db.sales_schema.orders
  ADD ROW ACCESS POLICY prod_db.governance_schema.region_row_policy
  ON (customer_region);

-- Audit: confirm attachment
SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES
WHERE POLICY_NAME = 'REGION_ROW_POLICY';

The mapping table pattern is flexible — you can join on user name instead of role, add additional segmentation dimensions, or drive it from a table managed by your identity system. The key constraint is that the protected table itself cannot be the mapping table. Snowflake rejects circular references. External tables are also unsupported as mapping tables.

The Gotchas

Dropping a masking policy that’s still attached fails — and the error is cryptic.You cannot DROP MASKING POLICY while it’s attached to any column. The error says the policy “cannot be dropped as it is associated with one or more entities.” To drop it: find all attachments with SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES WHERE POLICY_NAME = '<name>', then ALTER TABLE ... MODIFY COLUMN ... UNSET MASKING POLICY on each one, then drop. This is also why cloning a table with a masking policy requires the cloning role to have APPLY privileges — the clone carries the policy attachment.

Restoring a Time Traveled table can produce a masking policy error.If you drop a masking policy and then restore a table from Time Travel that was protected by that now-deleted policy, Snowflake throws: “Column already attached to a masking policy that does not exist.” The fix is to UNSET the ghost policy reference on the restored column and reapply a current policy. This is rare but will happen in a disaster recovery scenario if policies are managed separately from table DDL. Our Time Travel and Fail-safe guide covers the broader restore workflow.

A Row Access Policy on a large table without clustering turns COUNT(*) into a full scan.Without a RAP, SELECT COUNT(*) FROM big_table completes in milliseconds — Snowflake reads the metadata. With a RAP attached, Snowflake must evaluate the row filter for every row to count the visible ones, triggering a full table scan. For very large tables, clustering the table on the column used in the RAP filter (e.g. customer_region) lets Snowflake prune micro-partitions and dramatically reduces scan cost. The official Row Access Policy docs call this out explicitly under performance considerations.

Materialized views and masking policies don’t mix directly.You cannot create a materialized view that includes a column protected by a masking policy — Snowflake rejects it at creation time with an “unsupported feature” error. The workaround is to apply the masking policy after the materialized view is created, on the base table, not on the view. Alternatively, create the MV without the sensitive columns and handle masking in a view layer on top.

CURRENT_ROLE() returns the active role, not all roles the user has.A masking policy that checks CURRENT_ROLE() IN ('DATA_STEWARD') will not trigger for a user who has the DATA_STEWARD role but is currently operating under ANALYST. Users must explicitly USE ROLE DATA_STEWARD to activate that branch. If your governance model requires “any of your roles” logic, use IS_ROLE_IN_SESSION instead. This is one of the most common mis-implementations in the field and AI agent governance setups are particularly vulnerable because agents often operate under a single fixed role.

The One Principle

“Tag the schema, not the column. Write the policy once and let Snowflake enforce it on every table you load going forward — including the one at 2 AM that nobody remembers to govern manually.”

FAQ

What Snowflake edition is required for Dynamic Data Masking?

Enterprise Edition or higher is required for Dynamic Data Masking and Row Access Policies. Standard Edition provides basic role-based access control through privileges and object ownership, but not policy-driven column masking or row filtering. If you’re evaluating governance features, this edition requirement is often the first planning constraint to surface.

Can I apply two masking policies to the same column?

No. A column can have only one masking policy attached at a time. If you try to apply a second, Snowflake returns: “Specified column already attached to another masking policy.” The solution is to consolidate your logic into a single policy using CASE expressions, or to UNSET the existing policy before applying the new one. Tag-based policies and directly-assigned policies can coexist on the same column, but the directly-assigned policy takes precedence.

Does IS_AGENT_ACTIVATED work with MCP server connections?

Yes. When an AI agent connects to Snowflake through a managed MCP server and invokes a SQL tool, the resulting session is flagged as an agent context and IS_AGENT_ACTIVATED returns TRUE. This means masking policies with the IS_AGENT_ACTIVATED check will correctly block raw PII access for agents connecting via MCP — the same governance applies regardless of how the agent initiates the session.

How do I test a masking policy without applying it to a production column?

Use the POLICY_CONTEXT function. It simulates query execution as a specified role — including evaluating any masking or row access policies on the queried objects — and returns what that role would see. You can pass both role and session context into the simulation. This is the recommended pre-deployment validation approach from Snowflake’s own documentation.

Does a Row Access Policy affect Time Travel queries?

Yes. Row Access Policies apply to Time Travel queries using the AT or BEFORE clause — the policy evaluates against the current session context, not the historical role context. This means a user querying a historical snapshot only sees rows they are currently entitled to see, even if the entitlement table has changed since the historical timestamp.

Can Dynamic Data Masking and Row Access Policies be used together on the same table?

Yes, and this is the recommended pattern for most enterprise use cases. Row Access Policies filter which rows are returned; Dynamic Data Masking then controls what values are visible in those rows. Use POLICY_CONTEXT to simulate queries with both policies active simultaneously to verify the combined behavior before applying to production.

Related reading: Using MCP Servers with Snowflake · Cortex AI token usage monitoring · Identifying hidden Cortex AI token costs · Snowflake Time Travel and Fail-safe · Governing AI agents in Snowflake · Using Dynamic Data Masking (official) · Row Access Policies (official) · Tag-based masking policies (official)