Snowflake MCP Server Permissions: The Least-Privilege Setup
Quick answer: Snowflake MCP server permissions are four separate grants, not one: USAGE on the MCP server object, a grant on every tool it exposes, the object grants Cortex Analyst needs because it runs as the caller, and model access through GRANT APPLICATION ROLE SNOWFLAKE."CORTEX-MODEL-ROLE-...". Access to the server never implies access to its tools. The one thing RBAC will not save you from is a Cortex Search service, which runs with owner's rights.
Last updated: August 2026
Snowflake's managed MCP server is generally available, in commercial regions rather than government ones. Point Claude, Cursor or any MCP client at a Snowflake URL and it can query governed data with no proxy service in between. Every demo skips the question a security reviewer asks in the first five minutes: what identity is on the other end of that connection, and what can it reach?
The answer is not one grant. It is four, and they get collapsed into one by people who assume access to a server implies access to what it exposes. It does not. One object in the standard agent toolkit also runs with owner's rights and will hand a caller rows a row access policy would have hidden. Meanwhile per-model access moved to application-role RBAC, and CORTEX_MODELS_ALLOWLIST retires on a schedule that starts this month.
This is a security review, not a quickstart: a role definition you can paste, the owner's-rights hazard, the model RBAC migration, and one account parameter that may already be routing your inference to another cloud.
Who is the agent, actually?
An MCP client gets no special identity. It authenticates as a Snowflake user, through Snowflake's built-in OAuth service or a programmatic access token, and everything downstream is ordinary RBAC applied to that user's role. There is no separate agent principal to reason about.
That is reassuring until the first non-obvious rule. Cortex Agents determine session permissions from the querying user's default role, not the role active in the session. If a service user's DEFAULT_ROLE is SYSADMIN because someone set it up in a hurry, that is your security boundary. Callers also need a default warehouse, with USAGE granted to that same default role, or calls fail.
| Cortex object | Executes as | Caller's row access policies apply? | Practical effect |
|---|---|---|---|
| Cortex Analyst (generated SQL) | Caller's role | Yes | The agent sees what that human would see. Under-granting shows up as thin answers, not errors. |
| AISQL functions (AI_COMPLETE and friends) | Caller's role | Yes | Inference runs in the caller's session, so policies on the input table still apply. |
| Cortex Agent | The caller's default role | Yes | Not the session role. DEFAULT_ROLE on the service user is the actual control. |
| Cortex Search service | The service owner's role | No | Any role with USAGE can retrieve anything the service indexed. |
Snowflake MCP server permissions: the four layers of least privilege
Snowflake's docs are explicit that access to the MCP server does not grant access to the underlying tools; permission is granted per tool. Four layers, each with its own failure mode.
- Layer 1, reach the server.
USAGEon the MCP server object plus its database and schema lets a client connect and runtools/listandtools/call. It says nothing about data. - Layer 2, each tool.
USAGE ON AGENT,USAGE ON CORTEX SEARCH SERVICE,SELECT ON SEMANTIC VIEW: one grant per tool in the specification. - Layer 3, the data underneath. Cortex Analyst executes generated SQL as the caller, so the role also needs
SELECTon what the semantic view reads. The forgotten layer, and the symptom is an agent that answers confidently with nothing in it. Grant on a pre-filtered view, not base tables: a narrower object fails closed when someone edits the semantic model later. - Layer 4, inference. The
SNOWFLAKE.CORTEX_USERdatabase role (orCORTEX_AGENT_USER) plus theUSE AI FUNCTIONSaccount privilege, plus model access, which is now its own RBAC problem.
USE ROLE ACCOUNTADMIN; CREATE ROLE IF NOT EXISTS mcp_agent_role COMMENT = 'Identity for external MCP clients. Read-only, scoped to one schema.'; CREATE WAREHOUSE IF NOT EXISTS mcp_agent_wh WAREHOUSE_SIZE = XSMALL AUTO_SUSPEND = 60 INITIALLY_SUSPENDED = TRUE STATEMENT_TIMEOUT_IN_SECONDS = 120; GRANT USAGE ON WAREHOUSE mcp_agent_wh TO ROLE mcp_agent_role; -- Layer 1: reach the MCP server object itself. GRANT USAGE ON DATABASE ai_gov TO ROLE mcp_agent_role; GRANT USAGE ON SCHEMA ai_gov.agents TO ROLE mcp_agent_role; GRANT USAGE ON MCP SERVER ai_gov.agents.sales_mcp TO ROLE mcp_agent_role; -- Layer 2: every tool the server exposes, granted individually. GRANT USAGE ON AGENT ai_gov.agents.sales_agent TO ROLE mcp_agent_role; GRANT USAGE ON CORTEX SEARCH SERVICE ai_gov.agents.policy_search TO ROLE mcp_agent_role; GRANT SELECT ON SEMANTIC VIEW ai_gov.agents.sales_sv TO ROLE mcp_agent_role; -- Layer 3: the data the semantic view reads. Analyst runs as the caller. GRANT USAGE ON DATABASE analytics TO ROLE mcp_agent_role; GRANT USAGE ON SCHEMA analytics.marts TO ROLE mcp_agent_role; GRANT SELECT ON VIEW analytics.marts.v_sales_safe TO ROLE mcp_agent_role; -- Layer 4: inference. Both halves are required. GRANT DATABASE ROLE SNOWFLAKE.CORTEX_USER TO ROLE mcp_agent_role; GRANT USE AI FUNCTIONS ON ACCOUNT TO ROLE mcp_agent_role; -- One service user per client. No password, no interactive login. CREATE USER IF NOT EXISTS svc_mcp_claude TYPE = SERVICE DEFAULT_ROLE = mcp_agent_role DEFAULT_WAREHOUSE = mcp_agent_wh COMMENT = 'External MCP client'; GRANT ROLE mcp_agent_role TO USER svc_mcp_claude;
None of that helps while the account is still default-open. SNOWFLAKE.CORTEX_USER is granted to PUBLIC out of the box, so every user inherits it. A least-privilege story for agents, while every analyst can already call inference on any table they can read, is a fence with no gate.
USE ROLE ACCOUNTADMIN; -- See what PUBLIC already carries. SHOW GRANTS TO ROLE PUBLIC; -- Close the default, then grant deliberately per role. REVOKE DATABASE ROLE SNOWFLAKE.CORTEX_USER FROM ROLE PUBLIC; REVOKE USE AI FUNCTIONS ON ACCOUNT FROM ROLE PUBLIC;
Do this in a lower environment first and expect breakage: Snowsight features, notebooks and anything built on AI_COMPLETE stop working until you re-grant. That is the point, but it is not a Friday change. For scalar-function access without the full Cortex surface, AI_FUNCTIONS_USER is narrower and is not granted to PUBLIC.
Cortex Search runs with owner's rights - that is your RLS bypass
This is the finding that matters most and gets checked least. Cortex Search services search with owner's rights: any role that can query the service can retrieve any data the service indexed, regardless of its privileges on the tables behind the source query.
The consequence for row access policies is the surprising part. If the source table carries one, a user querying the service sees results from every row the owner's role can read, including rows their own role could never select directly. Multi-tenant RLS does not travel into the index. So a search tool is not governed by the caller's row policies, even though the Analyst path in the same agent is. Two tools, two security models, one chat window. Four things contain it:
- Index a filtered view, not the base table. The index then inherits the exclusion physically rather than by policy.
- One service per audience. Internal and external document sets get separate services and separate
USAGEgrants. - Pin a filter in the agent spec.
tool_resourcesaccepts afilterblock on a search tool. Defence in depth, not a boundary: it constrains what the agent asks for, not what the service could return. - Treat
USAGEon a search service as a data grant. The audit question is whether any role holding it would fail an RLS check on the source table. Every case where that is true is a privilege escalation, and it should be one somebody chose.
Never put SYSTEM_EXECUTE_SQL on the same MCP server as an agent
The specification supports several tool types and they are not equally dangerous. SYSTEM_EXECUTE_SQL lets the client run SQL directly, and Snowflake's own docs warn that exposing it on the same server as an agent lets the client bypass the agent's semantic views, verified queries and orchestration entirely. If you need direct SQL, use a separate MCP server with its own least-privileged role.
| Tool type | What it exposes | Blast radius if over-granted |
|---|---|---|
| CORTEX_AGENT_RUN | A governed agent | Bounded by the agent spec plus the caller's default role |
| CORTEX_ANALYST_MESSAGE | Text-to-SQL over a semantic view | Whatever the semantic view and the caller's grants reach |
| CORTEX_SEARCH_SERVICE_QUERY | A search index | Everything indexed, at owner's rights |
| SYSTEM_EXECUTE_SQL | Arbitrary SQL against the account | Everything the caller's role can read; bypasses the semantic layer |
| GENERIC | A UDF or stored procedure | Whatever the object does, and a stored procedure defaults to owner's rights |
That last row deserves a second look. Wrapping a stored procedure as a GENERIC tool is the tidiest way to give an agent a safe parameterised capability, and also the tidiest way to hand it an owner's-rights escape hatch, because EXECUTE AS OWNER is the Snowflake default. Read the procedure body before you expose it. And on a SYSTEM_EXECUTE_SQL tool, pin read_only, query_timeout and the warehouse in the specification. read_only defaults to true, which is the correct default and exactly the kind of thing that gets flipped during a demo and never reviewed again.
CREATE OR REPLACE MCP SERVER ai_gov.agents.sales_mcp
FROM SPECIFICATION $$
tools:
- title: "Governed sales agent"
name: "sales_agent"
type: "CORTEX_AGENT_RUN"
identifier: "ai_gov.agents.sales_agent"
description: "Use this agent for governed sales questions."
$$;
-- Clients connect at:
-- https://<account_url>/api/v2/databases/AI_GOV/schemas/AGENTS/mcp-servers/SALES_MCP
CORTEX_MODELS_ALLOWLIST is deprecating: migrate to model RBAC now
Per-model access used to be an account parameter: a comma-separated list of model names in CORTEX_MODELS_ALLOWLIST. Starting in August 2026 - this month - it can no longer be changed to a new value; the only permitted change is setting it to 'None'. Later in 2026 it is removed entirely. The replacement is application-role RBAC: one role per model, granted to the roles that should have it.
The migration is safe to do incrementally, because while both mechanisms exist they are additive: access is granted if the calling role holds the model's application role or the model name matches the allowlist, and denied only when both fail. Grant first, verify, then flip the parameter.
USE ROLE ACCOUNTADMIN; -- 1. What is the account running today? SHOW PARAMETERS LIKE 'CORTEX_MODELS_ALLOWLIST' IN ACCOUNT; -- 2. The full menu of per-model application roles. SHOW APPLICATION ROLES LIKE 'CORTEX-MODEL%' IN APPLICATION SNOWFLAKE; -- 3. Grant per model, per role. The identifier is quoted and case-sensitive. GRANT APPLICATION ROLE SNOWFLAKE."CORTEX-MODEL-ROLE-LLAMA3.1-70B" TO ROLE mcp_agent_role; -- Blanket access, if a role genuinely needs every model: -- GRANT APPLICATION ROLE SNOWFLAKE."CORTEX-MODEL-ROLE-ALL" TO ROLE analyst_role; -- 4. Verify from the grantee side before touching the parameter. SHOW GRANTS TO ROLE mcp_agent_role; -- 5. Only once every role has its grants: retire the allowlist. ALTER ACCOUNT SET CORTEX_MODELS_ALLOWLIST = 'None';
One trap that will cost you an afternoon if you script this blind: the casing is inconsistent between the two mechanisms. The allowlist parameter wants lowercase model names, as in 'mistral-large2,llama3.1-70b'. The application role embeds the model name in uppercase inside a quoted, case-sensitive identifier, as in SNOWFLAKE."CORTEX-MODEL-ROLE-LLAMA3.1-70B". Generating grants from an old allowlist string means upper-casing the model segment, then confirming each name against SHOW APPLICATION ROLES.
The compliance check almost nobody runs: CORTEX_ENABLED_CROSS_REGION
This finding lands hardest in a review, because nobody did anything wrong. For new accounts created in new organizations in commercial regions after 9 March 2026, ANY_REGION is the default value of CORTEX_ENABLED_CROSS_REGION. If nobody set the parameter, inference can already be routed to any Snowflake-supported region on any cloud provider.
State the mitigations accurately. Customer data stays stored only in your home region, and none is persisted at the processing region: prompt and response go there and the result comes back. Crossing cloud providers, traffic uses the public internet protected by mutual TLS between Snowflake endpoints. None of which helps if your contract says inference payloads containing customer data do not leave a named jurisdiction.
-- Where is inference allowed to run right now? SHOW PARAMETERS LIKE 'CORTEX_ENABLED_CROSS_REGION' IN ACCOUNT; -- Strictest posture: home region only. ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'DISABLED'; -- Or constrain to a jurisdiction you can defend in writing: -- ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'AWS_EU'; -- ALTER ACCOUNT SET CORTEX_ENABLED_CROSS_REGION = 'AWS_US,AWS_EU';
Accepted values run from ANY_REGION through provider-scoped options (AWS_GLOBAL, AZURE_GLOBAL, GCP_GLOBAL), region-scoped ones (AWS_US, AWS_EU, AZURE_US, AZURE_EU) and comma-separated combinations, down to DISABLED. Only ACCOUNTADMIN can change it. The tradeoff catches teams twice: model availability depends on cross-region routing, so DISABLED shrinks your model menu at the same time it strengthens your residency position, breaking any agent spec that names a specific model. Set the parameter first, then pick models, then test.
Which agent ran this? Making agent traffic auditable
Six weeks after go-live someone will ask why a row appeared in a chat answer, or why the Cortex bill jumped on a Tuesday. Answer from stored data, not memory. Three sources, which do not overlap neatly.
SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AGENT_USAGE_HISTORY. One row per agent call withUSER_NAME,REQUEST_ID,PARENT_REQUEST_ID, the agent name,TOKENSandTOKEN_CREDITS. Caveat: requests originating from Snowflake CoWork are not in this view, they land inSNOWFLAKE_COWORK_USAGE_HISTORY, so a full total needs both.- AI observability events.
SNOWFLAKE.LOCAL.GET_AI_OBSERVABILITY_EVENTS(db, schema, name, 'CORTEX SEARCH SERVICE')returns the logged events for a search service, the one path where owner's rights leaves the caller off the SQL. ReadRECORD_ATTRIBUTESon a real row before you build on it: the attribute keys are not a documented contract, so pin your parsing to what your account actually emits. QUERY_HISTORY. The SQL that Analyst generated and executed lands here like any other query, attributed to the service user. Tie it back with a query tag.
-- Who called which agent, and what did it cost?
SELECT
start_time,
user_name,
agent_database_name || '.' || agent_schema_name || '.' || agent_name AS agent,
request_id,
parent_request_id,
tokens,
token_credits
FROM snowflake.account_usage.cortex_agent_usage_history
WHERE start_time >= DATEADD('day', -7, CURRENT_TIMESTAMP())
ORDER BY start_time DESC;
-- Who hit the search service? Owner's rights is why this one matters.
-- Inspect RECORD_ATTRIBUTES on a real row before parsing specific keys out of it.
SELECT
timestamp,
record,
record_attributes
FROM TABLE(snowflake.local.get_ai_observability_events(
'AI_GOV', 'AGENTS', 'POLICY_SEARCH', 'CORTEX SEARCH SERVICE'
))
WHERE timestamp >= DATEADD('hour', -24, CURRENT_TIMESTAMP())
ORDER BY timestamp DESC;
-- Stamp every session from this client so QUERY_HISTORY is filterable.
ALTER USER svc_mcp_claude SET QUERY_TAG = '{"channel":"mcp","client":"claude"}';
SELECT query_id, user_name, role_name, warehouse_name, query_tag,
total_elapsed_time, bytes_scanned
FROM snowflake.account_usage.query_history
WHERE query_tag LIKE '%"channel":"mcp"%'
AND start_time >= DATEADD('day', -1, CURRENT_TIMESTAMP())
ORDER BY start_time DESC;
The query tag is why one service user per client matters. QUERY_TAG is a session parameter you set on the user object, so it rides along on every query that connection produces. Share one user across Claude, Cursor and an internal app and QUERY_HISTORY is one blob; split them and "which client ran this" is a WHERE clause.
OAuth, PATs, and the network policy you will forget
Snowflake OAuth is the default and the right answer for anything with a human behind it, because the identity in your logs is the person rather than a shared robot. External OAuth binds the server to Okta or Entra ID via the OAUTH_AUTHORIZATION_SERVER parameter. Programmatic access tokens are the fallback for headless clients, with three non-optional settings:
ROLE_RESTRICTION. Pins the token to one role, and for service users it is mandatory unless an authentication policy removes the requirement.- Expiry. A PAT expires after 15 days by default and can be set up to 365. Long expiry plus a token in a desktop config file plus no rotation runbook is how these become permanent credentials.
- Network policy. By default a user must be subject to a network policy with network rules to generate or use a PAT at all, unless an authentication policy changes
NETWORK_POLICY_EVALUATION.
ALTER USER IF EXISTS svc_mcp_claude ADD PROGRAMMATIC ACCESS TOKEN mcp_client_token ROLE_RESTRICTION = 'MCP_AGENT_ROLE' DAYS_TO_EXPIRY = 15 COMMENT = 'MCP client - rotate on the 1st, owner: data-platform'; -- The secret is returned once, in the token_secret column. There is no second chance.
The gotcha that eats an afternoon: when a remote MCP client connects to your server, the request originates from the client provider's infrastructure, not from your office or VPN. Teams write a network rule holding their corporate egress range, test from a laptop, and cannot work out why the hosted client is refused. The allowlist has to cover the provider's ranges, and those change.
Budget the orchestration loop, or it will budget you
The cost trap is not the price of one call. An agent is a loop: plan, call a tool, read the result, decide it needs another tool, repeat. A vague question against a semantic view with an ambiguous join can burn a lot of tokens deciding it cannot answer. The specification has a budget block for exactly this, in seconds and tokens.
CREATE OR REPLACE AGENT ai_gov.agents.sales_agent
COMMENT = 'Read-only sales Q&A, exposed through MCP.'
FROM SPECIFICATION
$$
# No models block. Omit it and Snowflake selects the orchestration model;
# name one only after checking it is available in your region.
orchestration:
budget:
seconds: 30
tokens: 16000
instructions:
response: "Answer only from the tools. If a tool returns nothing, say so plainly."
orchestration: "Use the analyst tool for numbers, the search tool for policy wording."
tools:
- tool_spec:
type: "cortex_analyst_text_to_sql"
name: "sales_analyst"
description: "Sales metrics from the governed semantic view."
- tool_spec:
type: "cortex_search"
name: "policy_search"
description: "Published, externally shareable sales policy documents."
tool_resources:
sales_analyst:
semantic_view: "ai_gov.agents.sales_sv"
policy_search:
name: "ai_gov.agents.policy_search"
max_results: "5"
filter:
"@eq":
audience: "external"
$$;
Pair it with the warehouse STATEMENT_TIMEOUT_IN_SECONDS and 60-second AUTO_SUSPEND from the role script earlier. The spec caps the reasoning, the warehouse caps the SQL, and without both a single pathological question can hold a warehouse open. Then put a weekly CORTEX_AGENT_USAGE_HISTORY query in front of whoever owns the bill, before they find it themselves.
A least-privilege review you can run this week
Nine checks, each a query or a decision, and each one has burned somebody.
- Is
CORTEX_USERstill granted toPUBLIC? If yes, your Cortex access control starts at "everyone". - What is the
DEFAULT_ROLEof every service user that talks to an agent? That role, not the session role, is the boundary. - For each Cortex Search service, would anyone holding
USAGEfail an RLS check on the source table? That gap is an escalation. - Does any MCP server expose
SYSTEM_EXECUTE_SQLalongside an agent? Split them onto separate servers with separate roles. - Are you still relying on
CORTEX_MODELS_ALLOWLIST? From this month it can only be set to'None', and it disappears later in 2026. - What is
CORTEX_ENABLED_CROSS_REGIONset to? If the account was created after 9 March 2026 and nobody touched it, assumeANY_REGION. - Do PATs have
ROLE_RESTRICTION, a short expiry and a named owner? Is rotation a calendar entry or an intention? - Does every agent spec have an orchestration budget? Seconds and tokens, both set.
- Can you answer "which client ran this query" from
QUERY_HISTORYalone? If not, split the service users and set a JSONQUERY_TAGper client.
A BI tool asks the questions you built it to ask. An agent asks whatever someone types. Design the grants for the second case.
Related Articles
Frequently Asked Questions
Q: What permissions does the Snowflake MCP server need?
Four layers. USAGE on the MCP server plus its database and schema, to connect and discover tools; a grant per tool (USAGE ON AGENT, USAGE ON CORTEX SEARCH SERVICE, SELECT ON SEMANTIC VIEW); SELECT on the underlying data, since Cortex Analyst runs its SQL as the caller; and inference access via CORTEX_USER, USE AI FUNCTIONS and the per-model application role.
Q: Can a Cortex Agent bypass row access policies?
On the Cortex Analyst path, no: generated SQL executes under the caller's role, so policies apply. On the Cortex Search path, effectively yes. Search services run with owner's rights, so a caller can retrieve results from rows the owner's role can read even when their own role cannot select those rows in the source table.
Q: How do I migrate off CORTEX_MODELS_ALLOWLIST?
Run SHOW APPLICATION ROLES LIKE 'CORTEX-MODEL%' IN APPLICATION SNOWFLAKE;, grant the model roles each role needs, verify with SHOW GRANTS, then set the parameter to 'None'. The two mechanisms are additive while both exist, so nothing breaks mid-migration. From August 2026 the parameter can only be changed to 'None', and it is removed later in 2026.
Q: Is cross-region inference enabled by default in a new Snowflake account?
For new accounts created in new organizations in commercial regions after 9 March 2026, ANY_REGION is the default for CORTEX_ENABLED_CROSS_REGION. Customer data stays stored in your home region and nothing is persisted at the processing region, but the inference payload does travel. Check the parameter before any data residency conversation.
Q: Should an MCP client authenticate with OAuth or a programmatic access token?
OAuth for anything with a human behind it, because the audit trail names the person rather than a shared robot, and external OAuth lets Okta or Entra ID own the access review. Use a PAT only for headless clients, with ROLE_RESTRICTION pinned to the narrow MCP role, a short expiry, and the network policy PATs require.
Q: Does the MCP server run queries as the agent owner or as the caller?
As the caller, with one twist: Cortex Agents determine session permissions from the querying user's default role, not the role active in the session, so DEFAULT_ROLE on the service user is the real boundary. The exception is a Cortex Search tool, which searches with the service owner's rights regardless of who called it.
