Since summer 2025, several major French data leaks have followed a similar pattern. No technical flaw is put forward. A valid account logs in. Then it reads tens or even hundreds of thousands of records.

After each leak, someone promises MFA everywhere. That is necessary. But MFA checks who logs in. It says nothing about how many records that account reads next.

TL;DR: incidents at France Travail and SFR show the risk of compromised accounts. MFA strengthens authentication, but does not count records read. First narrow what an account can see. Then give it a read budget per hour, and alert as soon as it runs over.

This article is for developers and CTOs whose app, back office or API holds customer records. No security background needed.

Separate confirmed incidents from a claim

This snapshot separates statements from the organizations and third-party accounts, as of October 31, 2026.

These cases motivate volume checks after login. They do not all establish the same attack path. A valid account can expose far more data than its usual activity requires.

MFA guards the door, not what walks out

MFA, multi-factor authentication, asks for a second proof on top of the password. A code on your phone, for example. It is the best defence against password theft. You need it everywhere.

After July 2025, France Travail said it would speed up two-factor authentication on the affected tool. After the tax leak, the tax administration planned stronger authentication for all its staff by the end of 2026. That is the right first answer.

MFA can also be required again for a sensitive action. On its own, though, it does not count the records read. Three cases show why that check matters.

  • A stolen session. Credential-stealing malware can also steal session cookies from the browser. Those cookies have already passed MFA.
  • An account created by another account. In December 2025, the attacker's two accounts were created with the rights of a real account. MFA checks the person. It does not tell you whether the account should exist.
  • A legitimate account that reads too much. A badly tuned script, an export started by mistake. MFA strengthens identity checks. It does not prove the volume is normal.

In all three cases, each request on its own looks normal. The account is logged in, it has the right, the app answers. The missing question is "how many?".

Three questions between a stolen account and your records

A stolen account passes MFA and the permissions, only a budget counts records. Each answer on its own is correct. The third question sets the size of the leak.

Add volume to your access checks

After login comes authorization. That is the code that decides whether this account can read this record. Two models dominate. RBAC decides by role. ABAC decides by attributes: who asks, for which resource, in which context.

I know both well. I built an ABAC system for application permissions. On an earlier contract, I coded role-based access to records.

Both models evaluate one request at a time. Can this account read this record? Yes. And the next one? Yes again. If the account may read the whole directory, the rule says yes 62,848 times. Every answer is correct.

A classic permission rule keeps no memory of past requests. It does not know this account has read 40,000 records since noon. A volume check needs a counter, including when an ABAC policy uses that counter.

In my article on ABAC in production, the worst danger was a code path that never asks for permission. An export, a batch job, an admin tool. Volume is the same problem, one step further. Even when every path asks, nobody counts the answers.

First, no account should be able to read everything

Start with the scope of permissions. Before you count, shrink what an account can see. An account that can read everything turns every credential theft into a full leak.

  • Limit each account to its own scope. A salesperson sees their customers, not the whole company's. A partner sees the files entrusted to them.
  • Separate the right to create accounts. In December 2025, one account was enough to open two more. An account that hands out access deserves its own monitoring.
  • Cap the page size. An API that accepts ?limit=100000 offers a full export to anyone with an account.
  • Make bulk export a separate right. Reading one record on screen and downloading the whole file are not the same need.
  • Give internal tools a read-only database account, limited to a few views. I covered that in the article on the Metabase leak.

These rules shrink the size of a leak. They still do not tell you when an account reads too much.

Give every account a read budget

A read budget is the maximum number of records an account can read per hour. Beyond it, the app refuses and warns someone. After the tax leak, the French government floated the same idea: quotas on data access, everywhere.

Count records, not requests. A classic rate limiter counts calls per minute and blocks beyond that. But a single call with a large page can return 10,000 records. What leaks is the volume.

Here is an example in Go. It is a generic, deliberately short example, not production code. It uses a middleware. A middleware is a function placed in front of your HTTP handlers. It sees every request and can refuse it.

// Example only: a read budget per account, counted in records, not requests. // shortcut: single-instance counters; use atomic shared counters before scaling out. type ReadBudget struct { mu sync.Mutex limit int // records per account per window window time.Duration // for example one hour counts map[string]int reset time.Time } // Spend reserves n records for the account. False means the budget is spent. func (b *ReadBudget) Spend(account string, n int) bool { b.mu.Lock() defer b.mu.Unlock() if now := time.Now(); now.After(b.reset) { b.counts = map[string]int{} b.reset = now.Add(b.window) } if account == "" || n < 1 || n > b.limit-b.counts[account] { return false } b.counts[account] += n return true } // The handler must use the supplied limit in its database query. func (b *ReadBudget) Middleware(next func(http.ResponseWriter, *http.Request, int)) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { account := AccountID(r.Context()) // set earlier by the auth middleware if account == "" { http.Error(w, "authentication required", http.StatusUnauthorized) return } size := pageSize(r) if !b.Spend(account, size) { slog.Warn("read budget exceeded", "account", account, "size", size) http.Error(w, "read limit reached", http.StatusTooManyRequests) return } next(w, r, size) }) } // pageSize validates the requested reservation; the handler enforces it. func pageSize(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil || n < 1 { return 20 } return min(n, 100) } 

What this example does:

  • The account identity comes from the authentication middleware, never from a request parameter.
  • The budget reserves at most 100 records. It passes that limit to the handler, which must use it in SQL.
  • Over budget, the app answers 429 and writes a warning to the logs.
  • With a budget of 250 records and pages of 100, two requests pass. The third gets a 429.

The handler receives the validated limit. It never reads ?limit= again. The SQL query uses that number without returning to the raw parameter:

// db is the application's *sql.DB; auth has already populated AccountID. handler := budget.Middleware(func(w http.ResponseWriter, r *http.Request, limit int) { rows, err := db.QueryContext(r.Context(), `SELECT id FROM records WHERE owner_id = $1 ORDER BY id LIMIT $2`, AccountID(r.Context()), limit) if err != nil { http.Error(w, "read failed", http.StatusInternalServerError) return } defer rows.Close() ids := make([]string, 0, limit) for rows.Next() { var id string if err := rows.Scan(&id); err != nil { http.Error(w, "read failed", http.StatusInternalServerError) return } ids = append(ids, id) } if err := rows.Err(); err != nil { http.Error(w, "read failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(ids); err != nil { slog.Error("write response", "error", err) } }) 

Reserve a page per call, even if the database returns fewer rows. This is a conservative bound, not an exact count. With a 250-record budget, three requests for 100,000 produce two 100-record pages, then a 429. Test both the returned row count and the charged budget. Full exports need a separate, authorized and logged path.

The example has two known limits. The window is fixed, so an account can read up to twice the budget at the edge between two windows. And the counters live in memory. With several instances, keep them in Redis, or each instance gets its own budget.

Alert first, block later

A badly tuned block breaks your teams' work. So start by alerting. The same counter serves both, with two thresholds.

  • A low alert threshold. It warns the team without blocking anything.
  • A higher block threshold. Depending on risk, it waits for the next window or requires human review. The latter needs a separate blocked state.

The delay matters as much as the threshold. According to Cyberattaque.org, several days reportedly passed between the extraction of the claimed records and the blocking of the access. A volume alert fires as soon as the counter runs over.

A few simple signals already cover a lot of cases.

  • An account that reads far more than its usual volume over the last 30 days.
  • An account created this week that already reads a lot.
  • An account that creates other accounts, especially outside usual hours.
  • An account that walks through records page after page, in ID order.

Send these alerts where someone reads them. A SIEM if you have one, otherwise a team channel. An alert nobody reads protects nothing.

Pick your thresholds from your own logs

A good threshold is measured, not guessed. For two to four weeks, count the records read per account and per hour, without blocking anything.

Then look at the heaviest readers of a normal week. Put the alert threshold just above them. Put the block threshold well above, where no normal use ever goes.

One threshold per role works better than a single one. Customer support, a manager and a sync job do not have the same needs. Technical accounts get their own budget too.

Finally, plan a clean way out. A real need for a bulk export does come up. It then goes through an approved, logged request, not a loop over the API.

The checklist

An account may read a record. Your code decides how many.

  • [ ] MFA everywhere, partner and contractor accounts included
  • [ ] Every account limited to its scope, no human account that can read everything
  • [ ] The right to create accounts kept separate, with an alert on each creation
  • [ ] A capped page size on every list route
  • [ ] Bulk export as a separate right, approved and logged
  • [ ] A counter of records read per account and per hour
  • [ ] Two thresholds measured on your logs, alert first, block second
  • [ ] A budget per role, technical accounts included
  • [ ] Alerts sent where someone reads them
  • [ ] A test that reads past the budget and expects a 429

What to remember

MFA answers "who are you?". Your permissions answer "may you read this record?". A third question is missing. It sets the size of the leak: "how many have you read?".

One day, a stolen account will get through the door. On that day, the gap between a few hundred records and 1.6 million is decided in your code. A counter and two thresholds are a good start.

Want to know how many records a single account can pull out of your app? Let's talk.

Sources: IT-Connect, France Travail and 340,000 job seekers (July 2025, in French) · France Travail, press release of 1 December 2025 (in French) · IT-Connect, 1.6 million young people (December 2025, in French) · IT-Connect, SFR confirms a data leak (August 2026, in French) · Cyberattaque.org, 62,848 staff records claimed (October 2026, in French) · JDN, the state's plan after the tax administration attack (in French)