
I tried checking how the 6 types of data protection policies on the provider side work on the consumer side in Snowflake Data Sharing
This page has been translated by machine translation. View original
This is Kawabata.
In this article, I will comprehensively verify how the 6 types of data protection policies defined on the provider side (masking, row access, projection, aggregation, join, and Data Movement Policy) behave on the consumer side.
Conclusion
These are the results measured by connecting two Enterprise Edition accounts in the same organization in the AWS Tokyo region via direct share, as of August 31, 2026.
| Policy | Does it take effect on the consumer side? | Notes |
|---|---|---|
| Masking Policy | ✓ | Works even when tag-based policy body is in a non-shared DB |
| Row Access Policy | ✓ | Evaluated with owner privileges even when mapping table is in a non-shared DB |
| Projection Policy | ✓ | Compile-time error. Can differentiate per share with INVOKER_SHARE() |
| Aggregation Policy | ✓ | Groups below the minimum are aggregated into a remainder group |
| Join Policy | ✓ | Requirements can be met by joining with a consumer-owned table |
| Data Movement Policy | Tag-based works / Account-level does not | Restricts fetch and unload. However, CTAS passes through |
There were two important points.
- A policy written to "mask only for a specific role" will expose raw values to all consumers across a share (because
CURRENT_ROLE()becomes NULL inside the policy, failing to match the mask condition)
-- Fail-closed approach (safe): only shows raw value for permitted roles
CASE WHEN CURRENT_ROLE() IN ('ANALYST_ROLE') THEN val
ELSE '***MASKED***' END -- ← masked even when the role cannot be determined
-- Fail-open approach (dangerous): masks only for a specific role
CASE WHEN CURRENT_ROLE() IN ('UNTRUSTED_ROLE') THEN '***MASKED***'
ELSE val END -- ← raw value is exposed when role cannot be determined
- Policies control query results and certain data movements, but cannot stop CTAS or re-sharing of data that has already been retrieved
Prerequisites
- Both accounts must be Enterprise Edition or higher
- Accounts must be in the same region
- Verification date: August 31, 2026, Snowflake Enterprise Edition, AWS Tokyo region
- Account identifiers in this article have been replaced with sample values (
myorg.provider_acct/myorg.consumer_acct)
Verification Perspectives
For each policy type, the following perspectives were measured.
| Perspective | What was checked |
|---|---|
| Application | Is it enforced by the consumer's query? Is the error at compile time or runtime? |
| Context functions | How are CURRENT_ROLE() etc. inside the policy body evaluated on the consumer side? |
| Definition visibility | Can the consumer see the policy body or tags? |
| Consumer-side application | Can the consumer add policies to shared objects? |
| Change propagation | When are provider's policy changes or removals reflected? |
| Protection after export | Does protection remain after CTAS, re-sharing, or unloading? |
Preparation
We create a verification DB and two shares on the provider side. The reason for creating two shares is to demonstrate per-share branching via INVOKER_SHARE() (described later) with a single consumer.
-- Provider side
CREATE DATABASE POLSHARE_DB; -- Target for sharing
CREATE DATABASE POLSHARE_LIB_DB; -- Not shared (centralized policy management)
CREATE SHARE POLSHARE_SHARE_A;
GRANT USAGE ON DATABASE POLSHARE_DB TO SHARE POLSHARE_SHARE_A;
-- (Granting schema USAGE and table SELECT is omitted)
ALTER SHARE POLSHARE_SHARE_A ADD ACCOUNTS = myorg.consumer_acct;
CREATE SHARE POLSHARE_SHARE_B; -- Also share the same table via a different share
GRANT USAGE ON DATABASE POLSHARE_DB TO SHARE POLSHARE_SHARE_B;
-- (Grant schema USAGE and table SELECT for target schema as with SHARE_A)
ALTER SHARE POLSHARE_SHARE_B ADD ACCOUNTS = myorg.consumer_acct;
Mount the shares on the consumer side.
-- Consumer side
CREATE DATABASE POLSHARE_A_DB FROM SHARE myorg.provider_acct.POLSHARE_SHARE_A;
CREATE DATABASE POLSHARE_B_DB FROM SHARE myorg.provider_acct.POLSHARE_SHARE_B;
GRANT IMPORTED PRIVILEGES ON DATABASE POLSHARE_A_DB TO ROLE POLSHARE_CONSUMER_ADMIN;
GRANT IMPORTED PRIVILEGES ON DATABASE POLSHARE_B_DB TO ROLE POLSHARE_CONSUMER_ADMIN;
Note: For consumer-side verification,
ALTER SESSION SET USE_CACHED_RESULT = FALSE;andUSE SECONDARY ROLES NONE;are executed at the start of each session. The former is to prevent false positives in change propagation verification, and the latter is to block privilege bypasses via held roles.
Let's Try It
Masking Policy
To see the difference depending on how the policy is written, I applied policies with different approaches to each column in a single table.
Masking Policy
-- Allowlist type: shows raw value only for permitted roles
CREATE MASKING POLICY MP_ALLOWLIST AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() IN ('POLSHARE_PROV_ADMIN') THEN val
ELSE '***MASKED***' END;
-- Blocklist type: masks only for a specific role (raw value is shown when role cannot be determined = fail-open)
CREATE MASKING POLICY MP_BLOCKLIST AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() IN ('UNTRUSTED_ROLE') THEN '***MASKED***'
ELSE val END;
-- Debug policy that returns the evaluated values of context functions as-is
CREATE MASKING POLICY MP_DEBUG AS (val STRING) RETURNS STRING ->
'ROLE=' || NVL(CURRENT_ROLE(), '<NULL>')
|| '|USER=' || NVL(CURRENT_USER(), '<NULL>')
|| '|ACCTNAME=' || NVL(CURRENT_ACCOUNT_NAME(), '<NULL>')
|| '|SHARE=' || NVL(INVOKER_SHARE(), '<NULL>');
Results of SELECT on the consumer side.
| Column (approach) | Provider | Consumer |
|---|---|---|
| Always masked | ***ALWAYS*** |
***ALWAYS*** |
| Always masked (policy body in non-shared DB) | ***ALWAYS_LIB*** |
***ALWAYS_LIB*** |
| Allowlist type (MP_ALLOWLIST) | Raw value | ***MASKED*** |
| Blocklist type (MP_BLOCKLIST) | Raw value | Raw value (leaks) |
| Debug column (MP_DEBUG) | ROLE=POLSHARE_PROV_ADMIN|... |
ROLE=<NULL>|USER=<NULL>|ACCTNAME=CONSUMER_ACCT|SHARE=POLSHARE_SHARE_A |

As the debug column shows, inside a policy on a shared object, CURRENT_ROLE() / CURRENT_USER() become NULL. This causes the CASE condition to be UNKNOWN, falling through to the ELSE clause.

- Allowlist type: ELSE is masked → fails closed (safe side)
- Blocklist type: ELSE is raw value → raw value leaks to all consumers
The blocklist approach works within the same account, but when placed on a share, the role cannot be determined, causing it to fall to the side that exposes the raw value (fail-open). To flip to the safe side without drastically changing the existing policy design, you can explicitly detect NULL.
CREATE MASKING POLICY MP_NULLSAFE AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() IS NULL THEN '***SHARE_CTX***' -- Cross-share access
WHEN CURRENT_ROLE() IN ('POLSHARE_PROV_ADMIN') THEN val
ELSE '***MASKED***' END;
On the other hand, CURRENT_ACCOUNT() / CURRENT_ACCOUNT_NAME() / CURRENT_ORGANIZATION_NAME() / INVOKER_SHARE() are correctly evaluated with the consumer-side values. Use these for branching in share-aware policies.
-- Raw value for provider only, masked for shared destinations
CREATE MASKING POLICY MP_ACCT AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ACCOUNT_NAME() = 'PROVIDER_ACCT' THEN val
ELSE '***CONSUMER***' END;
Note: Since
CURRENT_ACCOUNT()(the locator) can change during account migration, using the combination ofCURRENT_ACCOUNT_NAME()andCURRENT_ORGANIZATION_NAME()is recommended.
Tag-based masking was also enforced in the same way. Additionally, placing the policy body in a separate DB that is not shared had no impact on enforcement across the share.
Row Access Policy
Since a row access policy only returns rows that evaluate to TRUE, rows where the condition evaluates to NULL (UNKNOWN) are not returned. Unlike masking policies, it fails closed (safe side).
Row Access Policy
-- Role-dependent RAP: CURRENT_ROLE()=NULL across a share → all rows excluded (0 rows)
CREATE OR REPLACE ROW ACCESS POLICY POLSHARE_DB.S_RAP.RAP_ROLE
AS (region STRING) RETURNS BOOLEAN ->
CURRENT_ROLE() IN ('POLSHARE_PROV_ADMIN', 'ACCOUNTADMIN');
-- Account-branching RAP: only shows APAC to consumers (multi-tenant delivery pattern)
CREATE OR REPLACE ROW ACCESS POLICY POLSHARE_DB.S_RAP.RAP_ACCT
AS (region STRING) RETURNS BOOLEAN ->
CURRENT_ACCOUNT_NAME() = 'PROVIDER_ACCT' OR region = 'APAC';
-- Mapping table reference RAP (mapping included in the share)
CREATE OR REPLACE ROW ACCESS POLICY POLSHARE_DB.S_RAP.RAP_MAP_S
AS (region STRING) RETURNS BOOLEAN ->
EXISTS (SELECT 1 FROM POLSHARE_DB.S_RAP.REGION_MAP m
WHERE m.region = region AND m.account_name = CURRENT_ACCOUNT_NAME());
-- Mapping table reference RAP (mapping in non-shared LIB DB. Works because evaluated with owner privileges)
CREATE OR REPLACE ROW ACCESS POLICY POLSHARE_DB.S_RAP.RAP_MAP_L
AS (region STRING) RETURNS BOOLEAN ->
EXISTS (SELECT 1 FROM POLSHARE_LIB_DB.MAPS.REGION_MAP_LIB m
WHERE m.region = region AND m.account_name = CURRENT_ACCOUNT_NAME());
| Policy condition | Provider | Consumer |
|---|---|---|
CURRENT_ROLE() IN (...) |
6 rows | 0 rows (NULL → all rows excluded) |
CURRENT_ACCOUNT_NAME() = '...' OR region = 'APAC' |
6 rows | 2 rows (APAC only) |
| Mapping table reference (table in share) | 6 rows | 2 rows |
| Mapping table reference (table in non-shared DB) | 6 rows | 2 rows |

Even if the mapping table is not included in the share, the policy is evaluated with owner privileges so no error occurs. This allows row-level differentiation per tenant while keeping the mapping hidden from consumers.
Projection Policy
I measured the INVOKER_SHARE() branching shown in the official documentation sample.
Projection Policy
-- Per-share branching (official sample form): projection disallowed only via SHARE_A,
-- allowed via SHARE_B and by the provider itself
CREATE OR REPLACE PROJECTION POLICY POLSHARE_DB.S_PROJ.PP_BY_SHARE
AS () RETURNS PROJECTION_CONSTRAINT ->
CASE WHEN INVOKER_SHARE() = 'POLSHARE_SHARE_A' THEN PROJECTION_CONSTRAINT(ALLOW => false)
ELSE PROJECTION_CONSTRAINT(ALLOW => true) END;
Even with the same table and the same consumer, results differed depending on which share was used.
SELECT email FROM POLSHARE_A_DB.S_PROJ.CONTACTS; -- Via SHARE_A

SELECT email FROM POLSHARE_B_DB.S_PROJ.CONTACTS; -- Via SHARE_B → raw value retrieved

The same error occurs with EXPLAIN, so enforcement is at compile time. Note that non-projecting references such as WHERE email = '...' are allowed, so value existence inference is possible.
Aggregation Policy
Aggregation Policy
-- Provider has no restrictions; all others have a minimum group size of 5 (smaller groups go to remainder)
CREATE OR REPLACE AGGREGATION POLICY POLSHARE_DB.S_AGG.AP_COND
AS () RETURNS AGGREGATION_CONSTRAINT ->
CASE WHEN CURRENT_ACCOUNT_NAME() = 'PROVIDER_ACCT' THEN NO_AGGREGATION_CONSTRAINT()
ELSE AGGREGATION_CONSTRAINT(MIN_GROUP_SIZE => 5) END;
A plain SELECT * returns an error.

Results when a consumer aggregates a table with MIN_GROUP_SIZE => 5 set (APAC: 12 rows / EMEA: 5 rows / US: 3 rows).
SELECT region, COUNT(*) AS cnt FROM POLSHARE_A_DB.S_AGG.SALES GROUP BY region;

Join Policy
Join Policy
-- Consumer side: succeeds when joined with a table in their own DB
SELECT p.diagnosis, COUNT(*) AS cnt
FROM POLSHARE_A_DB.S_JOIN.PATIENTS p
JOIN POLSHARE_LOCAL_DB.WORK.LOCAL_PATIENTS l ON p.patient_id = l.patient_id
GROUP BY p.diagnosis;
A table with JOIN_CONSTRAINT(JOIN_REQUIRED => TRUE) set will block SELECT * with an error. When the consumer joins it with their own table, retrieval succeeds.


WHERE ... IN (subquery) does not satisfy the join requirement and results in the same error.

Data Movement Policy
Data Movement Policy (DMP) is a data export control feature that became GA in August 2026, and tag-based application is also included in the GA release.
The article from a previous verification is below.
The official documentation only contains the sentence "Cross-region share protection is not supported," and the behavior with same-region shares is not described. Based on actual measurements, tag-based DMP worked across shares.
Data Movement Policy
-- Limit CLI/driver-based fetches to 100 rows (no limit for the verification role itself)
CREATE OR REPLACE DATA MOVEMENT RULE POLSHARE_DB.S_DMP.R_PSH_FETCH_100
TYPE = 'PROGRAMMATIC_FETCH'
MAX_ROWS AS () RETURNS INTEGER
-> (CASE WHEN SYS_CONTEXT('SNOWFLAKE$SESSION', 'ROLE') = 'POLSHARE_PROV_ADMIN' THEN NULL ELSE 100 END)
COMMENT = 'Fetch limited to 100 rows except provider admin';
-- Also limit Snowsight result display to 100 rows (additional rule for taking screenshots in Snowsight.
-- The actual error 100168 in the article body is from the PROGRAMMATIC_FETCH side)
CREATE OR REPLACE DATA MOVEMENT RULE POLSHARE_DB.S_DMP.R_PSH_SNOWSIGHT_100
TYPE = 'SNOWSIGHT_UI'
MAX_ROWS AS () RETURNS INTEGER
-> (CASE WHEN SYS_CONTEXT('SNOWFLAKE$SESSION', 'ROLE') = 'POLSHARE_PROV_ADMIN' THEN NULL ELSE 100 END)
COMMENT = 'Screenshot only: Snowsight display limited to 100 rows';
-- Block all unloads to internal/external stages (for COPY INTO verification)
CREATE OR REPLACE DATA MOVEMENT RULE POLSHARE_DB.S_DMP.R_PSH_COPY_INT_BLOCK
TYPE = 'COPY_INTO_INTERNAL_STAGE'
MAX_ROWS AS () RETURNS INTEGER
-> (CASE WHEN SYS_CONTEXT('SNOWFLAKE$SESSION', 'ROLE') = 'POLSHARE_PROV_ADMIN' THEN NULL ELSE 0 END)
COMMENT = 'Block unload to internal stage except provider admin';
CREATE OR REPLACE DATA MOVEMENT RULE POLSHARE_DB.S_DMP.R_PSH_COPY_EXT_BLOCK
TYPE = 'COPY_INTO_EXTERNAL_STAGE'
MAX_ROWS AS () RETURNS INTEGER
-> (CASE WHEN SYS_CONTEXT('SNOWFLAKE$SESSION', 'ROLE') = 'POLSHARE_PROV_ADMIN' THEN NULL ELSE 0 END)
COMMENT = 'Block unload to external stage except provider admin';
-- Policy that bundles rules as ENFORCE (blocks on violation)
CREATE OR REPLACE DATA MOVEMENT POLICY POLSHARE_DB.S_DMP.DMP_SHARE_GUARD
ENFORCE_RULES = (R_PSH_FETCH_100, R_PSH_SNOWSIGHT_100, R_PSH_COPY_INT_BLOCK, R_PSH_COPY_EXT_BLOCK)
COMMENT = 'Share guard: fetch/display <= 100 rows, block unload';
-- DMP is applied via tags (cannot be set on tags with PROPAGATE = NONE)
CREATE OR REPLACE TAG POLSHARE_DB.S_DMP.DMP_TAG
PROPAGATE = ON_DEPENDENCY_AND_DATA_MOVEMENT;
ALTER TAG POLSHARE_DB.S_DMP.DMP_TAG SET DATA MOVEMENT POLICY POLSHARE_DB.S_DMP.DMP_SHARE_GUARD;
ALTER TABLE POLSHARE_DB.S_DMP.PAYROLL SET TAG POLSHARE_DB.S_DMP.DMP_TAG = 'guarded';
-- Policy for account-level verification (defined here only. Applied/removed with ALTER ACCOUNT SET/UNSET for testing)
CREATE OR REPLACE DATA MOVEMENT RULE POLSHARE_DB.S_DMP.R_PSH_BASELINE_FETCH_BLOCK
TYPE = 'PROGRAMMATIC_FETCH'
MAX_ROWS AS () RETURNS INTEGER
-> (CASE WHEN SYS_CONTEXT('SNOWFLAKE$SESSION', 'ROLE') IN ('POLSHARE_PROV_ADMIN', 'ACCOUNTADMIN') THEN NULL ELSE 0 END)
COMMENT = 'Baseline test: block fetch except provider roles';
CREATE OR REPLACE DATA MOVEMENT POLICY POLSHARE_DB.S_DMP.DMP_TEST_BASELINE
ENFORCE_RULES = (R_PSH_BASELINE_FETCH_BLOCK)
COMMENT = 'Temporary account baseline for share verification';
Results of operations from the consumer side, with a fetch limit of 100 rows and unload prohibition applied via tags on the provider side.
| Consumer operation | Result |
|---|---|
SELECT COUNT(*) (1 row result) |
Success |
SELECT * ... LIMIT 50 |
Success |
SELECT * (fetch 500 rows) |
Runtime error |
COPY INTO @own stage |
Compile-time error |
CREATE TABLE ... AS SELECT * (500 rows) |
Success (passes through) |


Error because it exceeds 100 rows

Error because it is an unload to own stage

CTAS passes through and succeeds because it is outside the movement type

The important one is the last row.
Since CTAS is not included in DMP's control targets (movement types), all rows retrievable by a query can be copied to the consumer's own table. What is copied is purely the query result — it does not revert masked or row-access-policy-filtered data back to raw values. However, since tags and DMP are not propagated to the copy destination (PROPAGATE settings do not cross account boundaries), fetching and unloading from the copy destination are not restricted.
On the other hand, DMP applied at the account level (baseline) without tags did not block consumer operations. Only tag-based DMP works across shares.
Via Secure View
I also confirmed the configuration commonly used in practice: "don't share the base table, only share a secure view that references it."
Via Secure View
CREATE OR REPLACE TABLE POLSHARE_LIB_DB.BASE.CUSTOMERS_BASE (
id NUMBER, region STRING, ssn STRING, email STRING
);
INSERT INTO POLSHARE_LIB_DB.BASE.CUSTOMERS_BASE VALUES
(1, 'APAC', 'SSN-0001', 'base1@example.com'),
(2, 'EMEA', 'SSN-0002', 'base2@example.com');
CREATE OR REPLACE MASKING POLICY POLSHARE_LIB_DB.POLICIES.MP_BASE_ALWAYS
AS (val STRING) RETURNS STRING -> '***BASE_ALWAYS***';
-- Blocklist type (to confirm that raw values are visible across a share even via a view)
CREATE OR REPLACE MASKING POLICY POLSHARE_LIB_DB.POLICIES.MP_BASE_BLOCKLIST
AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() IN ('UNTRUSTED_ROLE') THEN '***MASKED***' ELSE val END;
CREATE OR REPLACE ROW ACCESS POLICY POLSHARE_LIB_DB.POLICIES.RAP_BASE_ACCT
AS (region STRING) RETURNS BOOLEAN ->
CURRENT_ACCOUNT_NAME() = 'PROVIDER_ACCT' OR region = 'APAC';
ALTER TABLE POLSHARE_LIB_DB.BASE.CUSTOMERS_BASE ALTER COLUMN ssn SET MASKING POLICY POLSHARE_LIB_DB.POLICIES.MP_BASE_ALWAYS;
ALTER TABLE POLSHARE_LIB_DB.BASE.CUSTOMERS_BASE ALTER COLUMN email SET MASKING POLICY POLSHARE_LIB_DB.POLICIES.MP_BASE_BLOCKLIST;
ALTER TABLE POLSHARE_LIB_DB.BASE.CUSTOMERS_BASE ADD ROW ACCESS POLICY POLSHARE_LIB_DB.POLICIES.RAP_BASE_ACCT ON (region);
-- Case 1: secure view referencing a base table with policies (no policies on the view itself)
CREATE OR REPLACE SECURE VIEW POLSHARE_DB.S_VIEW.V_BASEPOL AS
SELECT id, region, ssn, email FROM POLSHARE_LIB_DB.BASE.CUSTOMERS_BASE;
-- Case 2: policies directly on view columns (base table has no policies)
CREATE OR REPLACE TABLE POLSHARE_LIB_DB.BASE.CUSTOMERS_PLAIN (id NUMBER, email STRING);
INSERT INTO POLSHARE_LIB_DB.BASE.CUSTOMERS_PLAIN VALUES
(1, 'plain1@example.com'), (2, 'plain2@example.com');
CREATE OR REPLACE SECURE VIEW POLSHARE_DB.S_VIEW.V_VIEWPOL AS
SELECT id, email FROM POLSHARE_LIB_DB.BASE.CUSTOMERS_PLAIN;
CREATE OR REPLACE MASKING POLICY POLSHARE_DB.S_VIEW.MP_VIEWCOL_ALWAYS
AS (val STRING) RETURNS STRING -> '***VIEWCOL***';
ALTER VIEW POLSHARE_DB.S_VIEW.V_VIEWPOL ALTER COLUMN email SET MASKING POLICY POLSHARE_DB.S_VIEW.MP_VIEWCOL_ALWAYS;
| Where protection is applied | Result via view |
|---|---|
| Always-on masking on base table | Enforced (***BASE_ALWAYS***) |
| Row access policy on base table (account branching) | Enforced (target rows only) |
| Blocklist type on base table (fail-open) | Raw value leaks |
| Masking policy set directly on view column | Enforced (***VIEWCOL***) |

Even if the base table is not shared, the policy evaluation context remains cross-share (CURRENT_ROLE() = NULL).
Cross-cutting Verification
Definition visibility: Consumers cannot view the policy definition body or actual names (SHOW command for DMP was not verified).
SHOW MASKING POLICIES/SHOW ROW ACCESS POLICIES/SHOW AGGREGATION POLICIES/SHOW PROJECTION POLICIES/SHOW JOIN POLICIES(all withIN DATABASEspecified): all return 0 rowsDESCRIBE TABLEpolicy name column:Unknown Policy!(only the existence is visible)GET_DDL('TABLE', ...): operation itself is unsupported in shared DBs (error001131)

Consumer-side application: Applying tags or policies to shared tables is blocked with 003001: Provider share does not have sufficient privileges.

Limitations and Notes
- This verification is for a same-organization, same-region, direct share configuration. Cross-organization, cross-region, via listing, and reader account configurations have not been verified.
- Sharing of Dynamic Tables, materialized views, and external tables is not covered.
- The behavior where database role branching becomes "determined per share" deviates from the official documentation (which states it is enabled by granting to a role on the consumer side). Re-verification before incorporating into a design is recommended.
- Projection, aggregation, and join policies are features intended for trusted partners. The official documentation explicitly states that repeated inference attacks cannot be completely prevented.
- Tag-based application of row access, projection, aggregation, and join policies is in Public Preview as of August 2026, so these were verified with direct application (tag-based masking and DMP tag-based application are GA).
- DMP violation records can be delayed by up to 2 hours. Account for this delay when using for auditing.
Finally
The masking, row access, projection, aggregation, and join policies are all enforced across shares, and DMP only worked when applied via tags.
Ideally, sensitive data that doesn't need to be shared shouldn't be shared at all, but I think being able to use data protection policies when necessary is a benefit.
I hope this article is helpful to someone!