Your Snowflake login count is probably lying to you.
Most of the logins in your Snowflake account are not logins — they are driver OAuth refreshes and service-account connects that nobody is reading. One real account: 691 events, six interactive, and a method that surfaces what a single-account analyst would miss.
The number, delivered with confidence.
You are paying for Snowflake. Anyone who has built a metric on LOGIN_HISTORY has had the quiet suspicion: most of these events are not people — they are drivers and service accounts reconnecting on a timer. Here is the MFA check from a monitoring tool, on a real account, confirming the suspicion the hard way:
691 successful logins over the last 30 days. 0 used multi-factor authentication. 2 of 2 users can sign in with a password alone.
Read the way a dashboard hands it to you — flat, a fact — and it is a five-alarm finding. Six hundred and ninety-one unprotected logins. You open the incident channel.
Every number in that sentence is true. And the finding is still wrong.
The fact the dashboard didn't put next to it.
Most of these events are not human logins. LOGIN_HISTORY is event-level, not session-level: it records every authentication event — a person at a browser, yes, but also every driver reconnecting and every OAuth token refreshing on a timer. One human signing in once is one row; a background driver left running is hundreds. MFA is the example where this bites — any metric built on raw LOGIN_HISTORY counts inherits the same problem.
The scale shows it. The account is four days old with two users — one of them a person who signs in once or twice a working day. Forty-ish interactive logins a month would be a lot here, not seven hundred. The number doesn't fit the behaviour because most of it isn't behaviour. Split the same 691 events by client type and the fire goes out:
| Channel | Auth | Events | What it is |
|---|---|---|---|
| GO_DRIVER | OAuth | 635 | A driver refreshing its token on a timer |
| JDBC_DRIVER | password | 34 | A service account (DATABRICKS_READER) |
| PYTHON_SNOWPARK | OAuth | 16 | Snowpark refreshing a token |
| SNOWFLAKE_UI interactive | password | 4 | A person, at the browser |
| SNOWFLAKE_UI interactive | OAuth | 2 | The same person, federated session |
The two highlighted rows are the entire surface MFA addresses.
Six of the 691 events are interactive logins — a human, at a browser, where a second factor would ever be challenged. That is the entire surface the MFA recommendation addresses, and it concerns exactly one user.
The other 685 are not sign-ins. 651 are driver OAuth refresh (635 GO_DRIVER + 16 Snowpark). A token is issued once — and MFA does happen then, upstream at the identity provider — and is then refreshed automatically on a cadence. Every refresh writes a LOGIN_HISTORY row. A second factor at Snowflake's door is not the lever for these, and 651 of them is not a risk count. It is a heartbeat. Over a four-day-old account, that is ~160 refreshes a day — the signature of a timer, not a person.
The remaining 34 are a service account on password auth. MFA does not apply to non-interactive identities at all. There is a real finding here — that account should use key-pair authentication — but it is a different finding, and it is not "MFA."
The honest headline is not "691 logins, 0 with MFA." It is: six interactive logins for one human with no MFA, and one service account that should be on a key. Same data. A finding you can act on in an afternoon, instead of an alarm you can't act on at all.
Knowing the surface is six logins for one user makes the fix the small, scoped one. An authentication policy — CREATE AUTHENTICATION POLICY with MFA_ENROLLMENT = 'REQUIRED', applied at the account level via ALTER ACCOUNT SET AUTHENTICATION POLICY — is the current mechanism to require MFA for interactive users. For the service account, key-pair authentication so that JDBC row stops appearing as a password login in the first place.
Reframing is the expensive part.
The obvious response: fine, write a smarter MFA rule — one that filters to interactive client types. And we did. It is now a permanent check, and every audit from here gets the correct split automatically.
The rule did not find its own blind spot. Someone reframed the question — knowing that "691" was too large for a two-user account, that LOGIN_HISTORY is event-level, that GO_DRIVER refreshes on a timer — and only then could the rule be rewritten to ask the better one.
That reframing is the expensive part, and it does not come out of a dashboard. A dashboard scales the questions you already know to ask. It does nothing for the question you didn't.
SQL can go further than a single GROUP BY. Joining LOGIN_HISTORY to ACCOUNT_USAGE.USERS on USER_NAME adds the TYPE column (aliased as user_type below for readability) to every row. Run that and DATABRICKS_READER — a name that reads like a pipeline account — shows up typed PERSON. That is a structured catch, and once you have seen it you write the rule and it runs automatically. The known cases belong in SQL.
Structured cross-table checkLOGIN_HISTORY + USERSreadableSELECT u.name AS user_name, u.type AS user_type, l.reported_client_type AS client_type, l.first_authentication_factor AS auth_method, COUNT(*) AS events FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY l JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS u ON l.user_name = u.name WHERE l.event_timestamp >= DATEADD('day', -30, CURRENT_TIMESTAMP()) AND l.is_success = 'YES' GROUP BY 1, 2, 3, 4 ORDER BY events DESC;
On a two-user account this returns a handful of rows and the picture is complete. On a fifty-user account with custom connector names, legacy driver versions, and three years of historical drift, it returns forty rows and reading what each pattern means starts to require context that is not in any column.
Where the model earns its place.
This is the one job a Crosshire audit gives a language model. Not producing numbers — the rules and the SQL do that. The model's job is narrower: recognise patterns that a human analyst would not notice unless they had already seen them across many accounts, or specifically knew to look. An analyst working from first principles on a single account sees 635 GO_DRIVER events and wonders what they are. The model already knows: it has seen the shape across the platform — drivers that refresh on a timer, service accounts named after pipelines, OAuth flows whose MFA happens upstream. That is the asymmetry. A GROUP BY tells you 635 events came from GO_DRIVER via OAuth. It does not tell you that 160 refreshes a day over four days is the signature of a background connection pool left open, not a user doing anything. That context is not in any column; it lives in how the platform is generally operated.
After the rules run, the audit hands a model the finding and the pre-summary data underneath it, with this prompt:
Cortex discovery promptverbatimYou are reviewing a Snowflake audit. A rules engine has already produced
the finding below. Your job is NOT to restate it and NOT to produce any
numbers of your own. Your job: look at the raw breakdown and say, in plain
language, whether the finding — though numerically correct — would mislead
the reader, and why. If LOGIN_HISTORY events are dominated by driver OAuth
token refresh, say so: that is background traffic, not interactive
sign-ins, and MFA does not apply to it. Cite only numbers present in the
data.
The model returns, in substance:
The finding "691 logins, 0 with MFA" is numerically correct but would mislead a reader. Of the 691 events, 651 are driver OAuth token refresh — not human sign-ins; MFA is applied upstream when the token is first issued and cannot apply to a refresh. Only 6 events are interactive, allSNOWFLAKE_UI, concerning one user. The 34JDBC_DRIVERpassword events belong to a service account and call for key-pair auth, not MFA. All activity falls in a four-day window — 651 refreshes over four days is automated polling, not human activity. Report the finding as 6 interactive logins plus 1 service account on password auth; exclude the 651 refresh events from the MFA headline.
Every figure it cites was in the data it was handed. It produced no numbers of its own. The join query above surfaces DATABRICKS_READER typed PERSON — that is what the SQL gives you. What the model adds is what that pattern typically signals: an account named after a data pipeline but provisioned as a human identity is the kind of mismatch that accumulates when a team's Snowflake usage grows faster than its service-account policy. The query flags it; the model reads what it usually means, because it has seen the pattern before — not in your account, but across the platform. That is the case for using a model in a security audit at all. Not to summarise, not to produce numbers, but to bring audit experience that a single account, read by itself, will never give you.
Two rules we will not break.
The model never produces a number. Every credit figure, every percentage, every count in a Crosshire report comes from SQL and Python. The model reads those numbers and writes prose around them.
A human ratifies everything. The model's output is a draft observation for the audit lead, never a line that ships unread. On another account it might surface something that, on inspection, is noise. The lead decides.
SNOWFLAKE.CORTEX.COMPLETE — the in-warehouse function that runs this step inside a client's own account, so no data leaves their tenancy — is available on Enterprise accounts, not on trial accounts; and only in the AWS and Azure regions where Cortex LLM functions are GA — check the region matrix in the Snowflake docs. The breakdown numbers in this note are from a real trial account, where the rules engine runs fine. The discovery step shown here is the method exactly as it runs in a paid engagement; on the trial tier we run the identical prompt out-of-account to demonstrate it. We would rather tell you that than imply otherwise.
What this means for your account.
One thing to take from this:
A number is not a finding. — §6, the whole point
"691 logins, no MFA" is a number. "Six interactive logins for one user, plus a service account that needs a key" is a finding. The distance between the two is a person who knew the data well enough to distrust the first version — now helped by a model whose only job is to be suspicious on cue and show its reasoning.
You can check your own account in two minutes. This is the query — no audit tool required:
Run on your own accountLOGIN_HISTORYreadableSELECT COALESCE(REPORTED_CLIENT_TYPE, 'UNKNOWN') AS client_type, FIRST_AUTHENTICATION_FACTOR AS auth_method, COUNT(*) AS events FROM SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY WHERE EVENT_TIMESTAMP >= DATEADD('day', -30, CURRENT_TIMESTAMP()) GROUP BY 1, 2 ORDER BY events DESC;
ACCOUNT_USAGE.LOGIN_HISTORY has up to 2 hours of latency — a login from minutes ago may not appear yet. (If you land on INFORMATION_SCHEMA.LOGIN_HISTORY instead, note it is a table function with only 7 days of history, versus 365 days on ACCOUNT_USAGE.LOGIN_HISTORY — use the query above.)
If GO_DRIVER, PYTHON_DRIVER, JDBC_DRIVER or SNOWPARK rows dominate the total — they usually do — then your "login count," wherever you have seen it quoted, is mostly token refresh. The interactive number, the one MFA actually governs, is the SNOWFLAKE_UI rows. It is almost always far smaller, and far more useful, than the headline.
What is not cheap, and does not come out of a dashboard, is the reading: knowing which numbers mislead, and being able to show your work when you say so.
That reading is what a Crosshire audit is. A two-week engagement runs the 36 rules against your account, hands the raw breakdowns to a model for the discovery pass, and a human reviews every reframing before it reaches the report. You keep the findings, the queries, and the runbook — whether or not you commission what comes next.
Numbers in this note are from a Crosshire trial account, used so the method can be shown in public. The login-breakdown check described here ships in every Snowflake audit. — Crosshire