Nov 3, 2026 · 5 min read

What your web application's database account is allowed to read sets your breach size

Florida says 4.9 million people. That number was set by a database grant years earlier, not by the intrusion.

Article header: What your web application's database account is allowed to read sets your breach size

On October 29 the Florida Department of Education said it had identified "unauthorized criminal access to the financial aid website" on October 19, and that the incident "may have impacted approximately 4.9 million individuals". The department listed the elements involved: names, Social Security numbers, dates of birth, home addresses, phone numbers, email addresses and gender codes. It also said that anyone who has applied for financial aid in the state in the past could be among those affected, and that not all of those individuals were necessarily affected. It notified the Attorney General's office, the Florida Digital Service and FDLE. As of November 3 it has not said how the attackers got in — the same gap as every notice that names an incident without naming a vector.

The number came from a grant, not from the intrusion

4.9 million is about one in five Floridians — the Census Bureau's Vintage 2025 estimates put the state at 23,462,518 as of July 1, 2025. A state grant portal does not reach that figure out of a single award cycle. It reaches it because the department's own sentence is literally true: past applicants are in scope, and the store the portal queried still held them.

That is the part the coverage skips. The size was fixed years before anyone broke in, when somebody pointed an application at a database and granted its service account SELECT on the applicant tables — all of them, not the subset the live cycle serves. Whatever the entry point was, the attacker's reach ended where that account's reach ended. A file-read or an injection on one endpoint becomes a population-scale archive only if the principal behind it can already see the archive.

The public evidence is consistent with a compromise scoped to one application rather than the department. FLDOE named FloridaStudentFinancialAidSG.org. Checked November 3 at 18:31 UTC, www.floridastudentfinancialaidsg.org returns HTTP 503 with retry-after: 3600 and an "Under Maintenance — Office of Student Financial Assistance" page, from Microsoft-IIS/10.0 and X-Powered-By: ASP.NET at 108.59.20.52. The sibling OSFA host floridastudentfinancialaid.org returns 200 off the same stack at 108.59.20.51, Last-Modified August 18, 2025. That application has been offline at least since October 29, when CBS Miami captured the same maintenance page. The one beside it never stopped.

Timeline of the FLDOE financial aid breach: October 19, 2026, FLDOE identifies unauthorized criminal access to the financial aid website. Ten days later, October 29, 2026, FLDOE discloses publicly, states approximately 4.9 million individuals may be affected, and notifies the Attorney General, FDLE and the Florida Digital Service. Five days after that, November 3, 2026, www.floridastudentfinancialaidsg.org still returns HTTP 503 Under Maintenance while the sibling host floridastudentfinancialaid.org returns 200.

Scoping the principal your public application logs in as

Start from the connection string the running application actually uses, not the architecture diagram. For an IIS-hosted app that is the connectionStrings section of the deployed web.config or appsettings.json, plus any override in the IIS application settings. Note the principal it authenticates as — including when that is an integrated-auth app pool identity rather than a SQL login, since those are the ones that quietly end up in db_datareader.

Then stop reasoning about it and ask the database. The question is not what the ORM queries; it is what the login is permitted to query:

-- SQL Server: what can the web tier's principal actually read?
EXECUTE AS USER = 'app_web';
SELECT permission_name, state_desc
  FROM fn_my_permissions(NULL, 'DATABASE') WHERE permission_name = 'SELECT';
SELECT COUNT(*), MIN(submitted_utc), MAX(submitted_utc) FROM dbo.Applications;
REVERT;

The second query is the one that matters. If the oldest row it returns predates the application's current purpose by a decade, you have found your breach size, and no CVE count or scan report will tell you the same thing.

The fix is to stop granting tables and start granting a view. The public application gets SELECT on a view filtered to the window it serves — the open cycle plus whatever lookback the UI genuinely renders — and DENY SELECT on the base tables. The history still has to be reachable, because returning applicants expect their award record and staff handle appeals on old cycles; it just should not be reachable by the principal that answers requests from the internet. That is a second, non-routable service account behind an internal endpoint.

The cost is real: nobody has a list of the queries a twenty-year-old portal issues, so you build it from Query Store or an Extended Events session over at least one full application cycle, and the first time you narrow the grant you will break a report that no one documented. That is a few weeks of work, most of it waiting. It is also the only way the inventory gets written, and the absence of that inventory is what turns a four-day detection into a 380-day notification.

If you cannot re-scope the role this quarter, instrument it. Log statements for that principal specifically and alert on result-set cardinality above what any legitimate page renders — a portal that serves one applicant per request has no business returning 50,000 rows. CareCloud's notice described six days of database access that nobody observed; the signal was available and unwatched.

What to check this week

  • Pull the connection string from the deployed config of every internet-facing application and list the distinct database principals. Expect duplicates, and expect at least one administrative account.
  • For each principal, run the permissions query above. Record whether it holds any server-level or db_owner role, and whether it can read tables no page displays.
  • For the largest table it can read, get MIN() on the created-date column. Compare that date to the retention period your records schedule claims.
  • Confirm whether that principal's statements are logged anywhere you can query, and how long those logs are kept.
  • Check whether the same principal is reused by a batch job or a reporting tool. Shared credentials are how a narrow web grant becomes a wide one.

North InfoSec runs AI-assisted penetration testing and security assessments, including the data-layer privilege testing described above. northinfosec.com

← All articles