
I tried to verify 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 using two Enterprise Edition accounts in the same organization in the AWS Tokyo region, connected via direct share, as of August 31, 2026.
| Policy | Does it take effect on the consumer side? | Notes |
|---|---|---|
| Masking Policy | ✓ | Takes effect even when the tag-based policy body is in a non-shared DB |
| Row Access Policy | ✓ | Evaluated with owner privileges even when the mapping table is in a non-shared DB |
| Projection Policy | ✓ | Compile-time error. Can differentiate per share using 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.
- Policies written to "mask only for specific roles" will expose raw values to all consumers across shares (because
CURRENT_ROLE()becomes NULL inside the policy, causing it not to match the masking condition)
-- Fail-closed approach (safe side): only show raw value for allowed roles
CASE WHEN CURRENT_ROLE() IN ('ANALYST_ROLE') THEN val
ELSE '***MASKED***' END -- ← Also masked when role cannot be determined
-- Fail-open approach (dangerous side): mask only for specific roles
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 prevent CTAS or re-sharing of data that has already been retrieved
Prerequisites
- Both accounts must be Enterprise Edition or higher
- Accounts 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, measurements were taken from the following perspectives.
| Perspective | What was verified |
|---|---|
| Whether applied | Is it enforced by consumer queries? Is the error at compile time or runtime? |
| Context functions | How CURRENT_ROLE() and similar functions inside the policy body are evaluated on the consumer side |
| Definition visibility | Can the consumer see the policy body and tags? |
| Application from the receiving side | Can the consumer add policies to shared objects? |
| Change propagation | When are provider policy changes and removals reflected? |
| Protection after extraction | Does protection remain after CTAS, re-sharing, or unload? |
Preparation
We create a verification DB and two shares on the provider side. The reason for two shares is to demonstrate per-share branching using INVOKER_SHARE() (described later) with a single consumer.
-- Provider side
CREATE DATABASE POLSHARE_DB; -- Shared target
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 target schema USAGE and table SELECT 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;were executed for each session. The former prevents false positives in change propagation verification, and the latter blocks privilege bypass via held roles.
Testing
Masking Policy
To examine differences based on how the policy is written, policies with different approaches were applied per column to a single table.
Masking Policy
-- Allowlist type: show raw value only for allowed 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: mask only for specific roles (raw value shown when undeterminable = 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 context function values directly
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 (leaked) |
| Debug column (MP_DEBUG) | ROLE=POLSHARE_PROV_ADMIN|... |
ROLE=<NULL>|USER=<NULL>|ACCTNAME=CONSUMER_ACCT|SHARE=POLSHARE_SHARE_A |

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

- Allowlist type: ELSE is masked → falls to the safe side
- Blocklist type: ELSE is raw value → raw value is exposed to all consumers
The "mask only for specific roles" approach works within a single account, but when placed on a share, the role cannot be determined, causing it to fall to the side that shows raw values (fail-open). To safely flip the behavior without drastically changing existing policy designs, you can explicitly detect NULL.
CREATE MASKING POLICY MP_NULLSAFE AS (val STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() IS NULL THEN '***SHARE_CTX***' -- Access via share
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-compatible policies.
-- Show raw value for provider only, mask for share recipients
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()(locator) may change with account migration, usingCURRENT_ACCOUNT_NAME()in combination withCURRENT_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 effect on enforcement across shares.
Row Access Policy
Since row access policies return only rows evaluated as TRUE, rows where the condition becomes NULL (UNKNOWN) are not returned. Unlike masking policies, this falls to the safe side.
Row Access Policy
-- Role-dependent RAP: CURRENT_ROLE()=NULL across 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: show only 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 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 is shared) | 6 rows | 2 rows |
| Mapping table reference (table in non-shared DB) | 6 rows | 2 rows |

Even without including the mapping table in the share, the policy is evaluated with owner privileges, so no error occurs. This allows rows to be selectively exposed per tenant while keeping the tenant mapping hidden from consumers.
Projection Policy
I measured the INVOKER_SHARE() branching from the official documentation sample.
Projection Policy
-- Per-share branching (official sample format): projection disabled only via SHARE_A,
-- projection 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 on the same table with 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 it is possible to infer whether a value exists.
Aggregation Policy
Aggregation Policy
-- Provider has no restrictions; all others have minimum group size of 5 (groups below that 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 regular SELECT * results in an error.

Results when aggregating from the consumer on 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 by joining 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 have SELECT * blocked with an error. The data could be retrieved when the consumer joined it with their own table.


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 extraction control feature that went GA in August 2026, and tag-based application is also included in GA.
The article from a previous verification is below.
The official documentation only has a single line saying "Cross-region share protection is not supported," with no mention of behavior for same-region shares. Based on actual measurements, tag-based DMP functioned across shares.
Data Movement Policy
-- Limit CLI/driver fetch 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';
-- Limit Snowsight 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 bundling Rules as ENFORCE (block on exceed)
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 applied via tag (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 and removed using ALTER ACCOUNT SET/UNSET for verification)
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 more than 100 rows

Error because unloading to own stage

CTAS passes through successfully as it is outside the movement type

The last row is important.
CTAS is not included in DMP's control targets (movement types), so all rows retrievable by the query can be copied to the consumer's own table. What gets copied is only the query result, so masking and row access policy results are not reverted to raw values. However, since tags and DMP are not propagated to the copy destination (PROPAGATE settings do not cross account boundaries), fetch and unload 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 functions across shares.
Via Secure View
We also verified the common real-world setup of "share only a secure view that references the base table, without sharing the base table itself."
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***';
-- "Mask only for specific roles" type (also verifying that raw values are exposed across shares 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 the base table with policies (no policy 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: Policy directly on view columns (no policy on base table)
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 |
|---|---|
| Base table always-on masking | Enforced (***BASE_ALWAYS***) |
| Base table row access policy (account branching) | Enforced (target rows only) |
| Base table "mask only for specific roles" type (fail-open) | Raw value is leaked |
| Masking policy applied directly to view column | Enforced (***VIEWCOL***) |

Even without sharing the base table, 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 its existence is visible)GET_DDL('TABLE', ...): operation itself is not supported in shared DBs (error001131)

Application from the receiving side: Applying tags and policies to shared tables is blocked with 003001: Provider share does not have sufficient privileges.

Limitations and Notes
- This verification is based on a same-organization, same-region, direct share configuration. Cross-organization, cross-region, listing-based, and reader account configurations have not been verified
- Sharing of Dynamic Tables, materialized views, and external tables is out of scope
- The behavior where database role branching becomes "per-share evaluation" diverges from the official documentation explanation (activated by granting to a role on the consumer side). Rechecking before incorporating into a design is recommended
- Projection, aggregation, and join policies are features intended for trusted partners. The official documentation explicitly states that they cannot fully prevent estimation attacks through repeated attempts
- Tag-based application of row access, projection, aggregation, and join policies is in public preview as of August 2026, so these were verified using direct application (tag-based masking and tag-based DMP application are GA)
- DMP violation records can be delayed by up to 2 hours. Please factor in this delay when using for auditing purposes
Closing
Masking, row access, projection, aggregation, and join policies are all enforced across shares, and DMP only functioned when applied via tags.
While the premise should be to not share confidential data that doesn't need to be shared in the first place, I think it's an advantage to have data protection policies available when needed.
I hope this article is helpful in some way!
