Crosshire
The finding, the fix — no queries Full audit with all SQL and provenance
Security·Field report·7 min read·25 May 2026

Snowflake gave every user access to Cortex AI by default.

The release notes on 13 May said granularity arrives. The GRANTS_TO_ROLES view, opened ten days later in the same account, said something different: granularity arrives after the default already says yes. Both lines are true. Only one of them gets read — and the bill keeps running while the wrong one is on the screen.

12 / 14
active users one keystroke from spending Cortex credits
Six already are. Nobody ratified them. n_per_function_grants: 0.
n_can_today: 12 · n_did_30d: 6 · the new GA privilege, still idle.
Provenance
1database role
PUBLICdefault grantee
8+cortex functions
2026-05-13privilege GA
1controlled-trial account audited
Platform
Snowflake on AWS, eu-west-1, Enterprise edition
Audit window
2026-04-25 → 2026-05-25 (30-day rolling, inspected 2026-05-25)
Surfaces
SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES · SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AI_FUNCTIONS_USAGE_HISTORY · SHOW GRANTS ON DATABASE SNOWFLAKE
Sources
Snowflake Cortex AI access control docs · the May 2026 USE AI FUNCTION GA release note
Cost basis
Not a billing audit; this is access-control surface area — who can spend, not who is
Anonymised Crosshire trial workspace Method-only public release
01The lock and the door

The release note and the grants view tell different stories.

Open the 13 May release note and you are reading about a new lever. Open SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES in the same browser tab and you are reading about a door that has been ajar since the account was provisioned. Two paragraphs, five tabs apart. Both true. We ran the query first, then went back to the release note — and only then noticed the difference between what was being announced and what was already the case.

The lever is the lever the docs lead with: per-function USE AI FUNCTION <name> privileges, GA on 2026-05-13, scoped to each entry in the Cortex catalog. AI_COMPLETE (legacy SNOWFLAKE.CORTEX.COMPLETE), AI_SUMMARIZE (legacy SNOWFLAKE.CORTEX.SUMMARIZE), AI_CLASSIFY, AI_TRANSLATE, AI_EMBED — each grantable to a role on its own. On paper this is the privilege a security review has been asking for: a summarisation pipeline can be trusted with AI_SUMMARIZE without inheriting arbitrary AI_COMPLETE against a foundation model. The lock is well-designed.

The door is the SNOWFLAKE.CORTEX_USER database role, granted to PUBLIC at account creation. Every user the account has ever provisioned, and every user it ever will, inherits the right to call any Cortex function on first connection. The new privilege refines access along a path the default has already opened. Granting the new one without revoking the old changes nothing about who can spend.

Why this matters before the bill arrives
  • Cortex AISQL functions consume Snowflake credits on a per-token basis against the published per-model rate card, billed against the active warehouse. There is no per-user cap by default.
  • A single curious user with SELECT AI_COMPLETE(…) (or the legacy SNOWFLAKE.CORTEX.COMPLETE) in a worksheet can move a non-trivial number of credits before anyone notices.
  • The new USE AI FUNCTION privilege is the surface to fix this. Until you revoke the default, granting the new one changes nothing.
02Three numbers from ACCOUNT_USAGE

Entitled, exercised, and actually governed.

The shape of the gap is three integers. We pulled them in the order below because each one reframes the previous: the first tells you the surface area, the second tells you how much of that surface is actually used, and the third tells you how much is governed by anything other than the default. Read in sequence, they are the “here is your Cortex exposure” report most security reviews would have asked for if they knew the views existed.

Three numbers, one account · the Cortex AI exposure triplet
Number Source Meaning Value
n_can_today GRANTS_TO_ROLESUSERS Users entitled via PUBLICCORTEX_USER 12
n_did_30d CORTEX_AI_FUNCTIONS_USAGE_HISTORY Distinct users who actually called any Cortex function in the last 30 days 6
n_per_function_grants SHOW GRANTS ON DATABASE SNOWFLAKE Distinct USE AI FUNCTION grants currently in place (the new privilege) 0
Gap that matters n_can_today − n_did_30d — entitled, never observed 6

The triplet our worksheet returned matches the shape we expect every production account to return on first audit: n_can_today is essentially the active-user count (12 of 14 here), n_did_30d is a small fraction of that (6), and n_per_function_grants is exactly 0 because the new privilege has existed for under two weeks and nobody has touched it. The default did the granting. No human ratified the list.

Cortex AI exposure · entitled vs exercised vs governed three values, one account
ENTITLED · EXERCISED · GOVERNED · ONE ACCOUNT · LAST 30 DAYS n_can_today · 12 users entitled (PUBLIC → CORTEX_USER) n_did_30d · 6 n_per_function_grants · 0 (the new lever, idle) 0 all active users
The yellow bar is everyone the default grant entitles. The black bar is who actually exercised the entitlement. The thin red bar is who is governed by the new lever today. The ratification gap is the area in between.
Query 1 · is CORTEX_USER granted to PUBLIC?ACCOUNT_USAGE.GRANTS_TO_ROLESsql
-- Returns the row that says "yes, every user can call Cortex."
-- If the result is non-empty, n_can_today is the count of active users.
SELECT grantee_name,
       privilege,
       granted_on,
       name,
       created_on
FROM   snowflake.account_usage.grants_to_roles
WHERE  grantee_name = 'PUBLIC'
  AND  name ILIKE '%CORTEX_USER%'
  AND  deleted_on IS NULL
ORDER BY created_on DESC;
Query 2 · who actually called Cortex in the last 30 days?ACCOUNT_USAGE.CORTEX_AI_FUNCTIONS_USAGE_HISTORYsql
-- n_did_30d is the COUNT(DISTINCT user_name) of this result.
-- The per-function breakdown is the second cut: what is the entitled
-- population actually using? Pull query_id too so any one call can be
-- traced back through QUERY_HISTORY.
SELECT user_name,
       function_name,
       COUNT(*) AS call_count,
       SUM(tokens) AS tokens,
       MAX(query_id) AS last_query_id
FROM   snowflake.account_usage.cortex_ai_functions_usage_history
WHERE  start_time >= DATEADD('day', -30, CURRENT_TIMESTAMP())
GROUP BY 1, 2
ORDER BY 3 DESC
LIMIT 20;
Query 3 · per-function grants (the new privilege)SHOW GRANTS ON DATABASE SNOWFLAKEsql
-- The GA privilege is granted on the SNOWFLAKE database object.
-- Filter the result of SHOW GRANTS for "USE AI FUNCTION" rows;
-- n_per_function_grants is the count of those rows.
SHOW GRANTS ON DATABASE snowflake;

SELECT "privilege",
       "granted_on",
       "name",
       "grantee_name",
       "granted_by"
FROM   TABLE(RESULT_SCAN(LAST_QUERY_ID()))
WHERE  "privilege" ILIKE 'USE AI FUNCTION%';
Three queries, one account, fifteen minutes. The triplet is the “here is your Cortex exposure” report most security reviews would have asked for if they knew the view existed. — Field report, §2
03What the new privilege actually does

One privilege, one function, one role.

USE AI FUNCTION is granted on the SNOWFLAKE database, scoped to a named Cortex function. The grammar is regular SQL and the semantics are ordinary RBAC. Two grants below: one narrow, one as broad as the old database role:

A summarisation pipeline gets SUMMARIZE onlyUSE AI FUNCTIONsql
CREATE OR REPLACE ROLE cortex_summariser;

-- Either form works; the AI_* form aligns with current docs.
GRANT USE AI FUNCTION snowflake.cortex.summarize
  ON DATABASE snowflake
  TO ROLE cortex_summariser;
-- or, equivalently:
-- GRANT USE AI FUNCTION snowflake.ai.ai_summarize
--   ON DATABASE snowflake
--   TO ROLE cortex_summariser;

-- The role can call AI_SUMMARIZE (legacy SNOWFLAKE.CORTEX.SUMMARIZE),
-- and only that. AI_COMPLETE, AI_CLASSIFY, AI_TRANSLATE, AI_EMBED
-- (and their legacy SNOWFLAKE.CORTEX.* aliases) all return
-- "insufficient privileges" on this role.
GRANT ROLE cortex_summariser TO USER svc_summary_pipeline;
The wildcard, when you genuinely mean “all of Cortex”USE AI FUNCTIONsql
GRANT USE AI FUNCTION ON ALL ai_functions
  IN DATABASE snowflake
  TO ROLE cortex_power_user;

-- Equivalent surface to the old SNOWFLAKE.CORTEX_USER database role,
-- but now an explicit, named, auditable act — not a default.

The model surface is correct. Each privilege is a row in SHOW GRANTS; each call traces to the function and the granting role in CORTEX_AI_FUNCTIONS_USAGE_HISTORY; an Auditor role can review the lot without elevated access. Snowflake’s docs land this clearly. The piece they understate is what stays in place while you adopt the new privilege: the PUBLICCORTEX_USER grant is not revoked by adopting USE AI FUNCTION. You revoke it yourself, or it persists.

Five things to ask of every Cortex grant going forward
  • Function-scoped, not database-scoped. Grant the function the role needs. Resist ALL ai_functions except for explicit power-user roles.
  • Granted to a named role, never to PUBLIC. A grant to PUBLIC defeats the entire reason the privilege exists.
  • Paired with the corresponding revoke. Granting SUMMARIZE to a service role only matters if the CORTEX_USER default is gone for that role’s users.
  • Linked to a service account, not a human. Cortex roles for pipelines belong on service principals; for humans, on named “cortex-approved” roles only.
  • Recorded by the audit, not just the change ticket. The grant itself is the audit; SHOW GRANTS is the source of truth.
04The order the docs don’t put in order

Revoke. Inventory. Grant. In that order.

Read the docs top-down and you reach the new privilege first, the default-grant footnote later, the actual remediation last. That order leaves the default in place the whole time. The order that closes the door is the reverse: revoke the default, enumerate who actually used Cortex, re-grant per function against the named list. Revoke. Inventory. Grant. The only sequence that ends with a governed account.

Revoke. Remove the default CORTEX_USER grant from PUBLIC. This is a one-line statement. It is also the only step that is irreversible without a re-grant, and the only one that produces an immediate behaviour change for the population in n_can_today − n_did_30d. The expected outcome is “nothing breaks”, because by construction those users have not called Cortex in the audit window.

The one line that closes the default doorREVOKEsql
REVOKE DATABASE ROLE snowflake.cortex_user
  FROM ROLE "PUBLIC";

Inventory. Run query 2 from §2 with a wider window (90 days, not 30) to surface every user, function, and pipeline that has actually used Cortex. Group by warehouse_name as well as user_name to catch pipelines that use a service warehouse but a human principal. That list is the candidate set for the next step.

Grant. For each name on the inventory, decide: is this a human worksheet user (probably gets a single cortex_summariser-like role granting one function), a service principal (gets a tightly-scoped role with only the functions its pipeline calls), or an exploratory data-science role (gets USE AI FUNCTION ON ALL ai_functions, but only that role, only those users). Re-grant on the basis of the inventory, not on the basis of who used to have it.

The docs lead with the new privilege because that is what is new. The audit leads with the revoke, because that is what is unsafe. The order matters because in between the two reads, the bill keeps running. — Field report, §4
05A 20-minute audit you can run today

One worksheet, three queries, one decision.

The audit runs on any Snowflake account a SECURITYADMIN-equivalent role can read. Open a worksheet, run the three queries from §2, write down the triplet. If n_can_today − n_did_30d is large, you have a decision in front of you: revoke and re-grant, or document why the default was acceptable. The point is not the answer; it is to make the decision an act, rather than a non-act.

The Crosshire version of the audit adds warehouse spend attribution per Cortex function over the same 30-day window, plus a join against SNOWFLAKE.ACCOUNT_USAGE.USERS so the entitled set names its members rather than just counting them. The discipline is the same either way.

Every number above traces to a row in ACCOUNT_USAGE. A model never produces the numbers; a human ratifies every revoke. The audit is honest because the queries are.
Not in this post · future field notes
  • Cortex spend attribution per function. Joining CORTEX_AI_FUNCTIONS_USAGE_HISTORY to WAREHOUSE_METERING_HISTORY to turn token counts into credits per function per role. The shape, the gotchas, the rate-card edge cases.
  • The Cortex Search and Cortex Agents privilege surfaces. Separate from USE AI FUNCTION; same default-grant pattern; same revoke-first discipline. A follow-up note.
  • The ANALYST persona and USE AI FUNCTION together. The first role most accounts will want: read-mostly, with two or three explicit Cortex functions. A reference implementation.
From our audit
A Crosshire access-control audit ships the three queries above, run against your own ACCOUNT_USAGE, with the revoke-then-grant plan written against your actual user population — named, scoped, and reviewed by a human before any privilege is changed. The workbook stays with you. The triplet is the headline; the plan is the artefact.
Start a conversation →
Sources
· · ·

Numbers in this note come from a single controlled-trial Snowflake account — illustrative of the shape every first audit returns, not a universal benchmark. The queries above produce the same triplet on any account they are run against. The discipline is the artefact — query, count, decide, revoke, re-grant. The same triplet ships in every Crosshire access-control audit. — Crosshire

D
writes Crosshire Journal · crosshire.ch · May 2026
Crosshire Journal
Field reports on data, compute, and the unglamorous decisions that shape engineering teams. Made in EU. Cited evidence, GDPR-native.
Security·Quick fix·4 min read·25 May 2026

Every Snowflake user can call Cortex AI by default.

We opened a worksheet, ran one query against GRANTS_TO_ROLES, and the result came back with a sting: twelve of fourteen active users were already entitled to spend Cortex credits. None had asked. None had been ratified. The per-function privilege Snowflake shipped on 13 May is real RBAC. The door it locks was opened the day the account was created.

12 / 14
users one keystroke from spending Cortex credits
Six already are. Nobody ratified them. The new lever, idle.
n_can_today: 12 · n_did_30d: 6 · n_per_function_grants: 0
ENTITLED · EXERCISED · GOVERNED n_can_today · 12 entitled (PUBLIC → CORTEX_USER) n_did_30d · 6 n_per_function_grants · 0 (the new lever, idle) 0 all active users
The yellow bar is everyone the default grant entitles. The black bar is who exercised it. The thin red bar is who is governed by the new lever today.
The audit is one worksheet, three queries, twenty minutes. The decision afterwards is the work; the queries are only the lights. — Crosshire audit, trial workspace
Provenance · what we saw

1The release note for 2026-05-13 read like good news: per-function USE AI FUNCTION privileges had reached GA, with SUMMARIZE, COMPLETE, CLASSIFY, TRANSLATE and EMBED each grantable on their own. We opened a worksheet expecting to draft a role design.

2Before drafting anything, we asked the simpler question: who can already call Cortex today? One query against GRANTS_TO_ROLES returned the answer in seconds. SNOWFLAKE.CORTEX_USER was granted to PUBLIC. The new privilege was refining an access path that had been open since the account was created.

3Two more queries finished the picture. CORTEX_AI_FUNCTIONS_USAGE_HISTORY said six of the twelve had actually called Cortex in the last thirty days. SHOW GRANTS ON DATABASE SNOWFLAKE said zero USE AI FUNCTION grants were in place. Entitled, exercised, governed — twelve, six, zero. The triplet was the report.

01What the worksheet returned

Twelve users entitled, six already spending, none of them ratified.

We were drafting a role design for the new USE AI FUNCTION privilege when we paused to check a hunch. One SELECT against SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES — filter grantee_name = 'PUBLIC', look for CORTEX_USER, sort by created_on. The row came back. It had been there since the account was provisioned. Every user on the account had been entitled to spend Cortex credits from the first day they logged in.

The default did the granting. Nobody had to ask. The new per-function privilege is well-designed RBAC — the lock Snowflake shipped is the lock you would have asked for — but it refines an access path the default has already opened. Until you revoke the default, granting the new privilege narrows nothing.

02How the docs hide it

The release note announces the lever. The footnote owns the door.

Read the release notes top-down and you reach the new privilege first, the catalog of grantable functions second, the worked examples third. The line that says CORTEX_USER is granted to PUBLIC by default lives two clicks away on the access-control page, in the kind of paragraph a reader scans for context rather than for instruction. It is not hidden; it is just not where the action of the post is.

That is the trap. A reader who follows the docs in their natural order will adopt the new privilege without revoking the old default. Both stay in place. The default wins because the default is the union, not the intersection. The lock you just fitted does not actually lock until the door it is fitted to has been closed first.

03The remediation, in the order that matters

Revoke first. Then inventory. Only then grant.

The instinct is to start with the new privilege — grant USE AI FUNCTION to a couple of named roles and feel productive. That order leaves the default in place the whole time. The order that closes the door is the reverse: one REVOKE against PUBLIC, then an inventory pass over the real Cortex population from CORTEX_AI_FUNCTIONS_USAGE_HISTORY across a 90-day window, then per-function grants against that named list.

The one line that closes the default doorREVOKEsql
REVOKE DATABASE ROLE snowflake.cortex_user
  FROM ROLE "PUBLIC";

The revoke is the irreversible step and the only one that produces a behaviour change in the gap population (n_can_today − n_did_30d). The expected outcome is “nothing breaks”, because by construction those users had not been calling Cortex in the audit window anyway. The inventory query tells you who you do need to re-grant, with what scope.

A role that can SUMMARIZE and nothing elseGRANTsql
CREATE OR REPLACE ROLE cortex_summariser;

GRANT USE AI FUNCTION snowflake.cortex.summarize
  ON DATABASE snowflake
  TO ROLE cortex_summariser;

GRANT ROLE cortex_summariser TO USER svc_summary_pipeline;
Want the receipts?
The long version is the worksheet, with the queries open.
  • The exact SELECT we ran against GRANTS_TO_ROLES. Plus the per-function usage query against CORTEX_AI_FUNCTIONS_USAGE_HISTORY and the SHOW GRANTS recipe that surfaces USE AI FUNCTION rows. Copy-paste runnable on your own account.
  • The reasoning behind revoke before grant. Why the order matters, why the inventory query has to span 90 days not 30, and the role-design pattern that catches service-principal pipelines hiding behind human warehouses.
  • Five questions to interrogate every Cortex grant. A checklist for whether a proposed USE AI FUNCTION grant is scoped correctly, paired with its revoke, and bound to the right principal — the same list we use on a Crosshire audit.
D
writes Crosshire Journal · crosshire.ch · May 2026
Two-minute field fixes from the same audits as our long-form Journal. One number, one fix, one result you can verify.
Crosshire Quick
© 2026 Crosshire Journal · Made in EU Privacy Terms Cookies License Imprint Coffee