On 27 August 2026, Manchester Airports Group told customers that "an unauthorised third party" had stolen their data. Car park bookings, lounge bookings, Fast Track purchases for airport security and passport control, along with airport WiFi sign-ups across Manchester, Stansted and East Midlands. Roughly 8.8 million people.
A few days later, the group calling itself FulcrumSec claimed it, and said something that caught my eye. They said they didn't hack anything. They said they simply read an API key out of the website's JavaScript.
That's a very specific, very checkable claim. So I checked it.
Everything they described was there. Three keys, one per airport, in the page source, unrotated for over four years, for anyone to see.
What MAG said
MAG's incident page is what you'd expect. Email addresses, phone numbers, vehicle registrations and postcodes were taken. No bank or payment details. The incident was "immediately contained", specialist advisors were engaged, authorities were notified, and at no point was passenger safety or aviation security compromised.
What it doesn't say is how.
That's not unusual, and I'm not going to beat them up for it, but when the attacker makes a technical claim and the victim says nothing, the claim stands unchallenged, and I'd rather know whether it's true.
What FulcrumSec said
Their claim, as reported, was that they "obtained access using airport-specific Iterable API credentials exposed in client-side JavaScript." The first few times I read that, I read it as "iterable", as in it could be iterated/enumerated. Not as "Iterable", the brand!
- Iterable — a marketing automation platform. Customer profiles, segmentation, campaign history.
- Airport-specific — plural credentials, one per airport, not one shared key.
- Client-side JavaScript — shipped to the browser. Public by construction.

If all three are true, this isn't a breach in the way people picture one. There's no intrusion, no lateral movement, no malware. It's someone opening DevTools, good old 'F12 is hacking' stuff.
Where to look
MAG's three airport sites are Next.js applications served from CloudFront:
www.manchesterairport.co.uk → man.live.mag-dxp.com
www.stanstedairport.com → stn.live.mag-dxp.com
www.eastmidlandsairport.com → ema.live.mag-dxp.com
I started with the obvious thing: curl the homepage and grep it. Nothing. The HTML mentions Iterable, but only as CMS field names like iterableEventName, trackWithIterable, iterableCampaignId. Configuration for which marketing event a signup form fires. No credentials. Same story in __NEXT_DATA__.
This is the tricky thing with problems like this; if you only look at the HTML, you find nothing. The secret isn't in the DOM. It's in a JavaScript bundle the document loads, one of about ninety chunk files with content-hashed names, and people don't often read those. Not the developer who shipped it, not the reviewer, and not whatever scanner MAG was running, clearly.
So I pulled all ninety and grepped those instead. Seventeen of them mention Iterable, and this is what's in them:
function (e) {
let t = `${n?.IterableApi?.Url}/api/users/${e}`;
return r.A.get(t, { headers: { "Api-Key": n?.IterableApi?.Key } })
.then(e => {
e.data.user && window.sessionStorage.setItem("IterableUID", e.data.user.userId)
})
}
And alongside it:
function (e) {
let t = `${n?.IterableApi?.Url}/api/events/track`;
return r.A.post(t, e, { headers: { "Api-Key": n?.IterableApi?.Key } })
}
There it is. The browser makes a GET to https://api.iterable.com/api/users/{email} with an Api-Key header, and gets back a user object.
That first call looks really bad. It takes an email address and returns that person's profile. It's a server-side credential doing a server-side job, from inside the browser on the client-side, on a normal page load, for anyone who cared to look.
The config object that feeds it lives in pages/_app-*.js:
"IterableApi": { "Key": "", "Url": "https://api.iterable.com" }
It's now empty. Which tells you two things: the mechanism is exactly what FulcrumSec described, and somebody has since removed the key.
An empty string is not evidence of what used to be there, though. For that I needed the history.
The Internet never forgets
The Internet Archive's CDX API indexes far more than homepages. It has JavaScript, and MAG's _app chunk has been captured for years:
curl "http://web.archive.org/cdx/search/cdx?\
url=manchesterairport.co.uk&matchType=domain&output=text\
&fl=timestamp,original,statuscode,digest\
&filter=original:.*chunks/pages/_app-.*\
&filter=statuscode:200&collapse=digest"
393 unique captures for Manchester going back to 2020, along with 445 for Stansted, and 208 for East Midlands.
Fetch any one of them with the id_ modifier, which serves the original bytes rather than Wayback's rewritten version, decompress it, and grep away:
curl -s "https://web.archive.org/web/20260802022711id_/\
https://www.manchesterairport.co.uk/_next/static/chunks/pages/_app-6c91337bee847d36.js" \
| python3 -c "import sys,brotli,re; \
s=brotli.decompress(sys.stdin.buffer.read()).decode(); \
print(re.search(r'\"IterableApi\":\{[^}]*\}', s).group(0))"
"IterableApi": { "Key": "5273…517c", "Url": "https://api.iterable.com" }
A 32-character hexadecimal Iterable API key, in a file Manchester Airports Group had been serving to the public since 2022.
Three keys, over four years
I sampled every quarter from 2020 to today across all three domains, then dug deeper to find the exact dates. Here's the timeline.
| Site | Key | Absent | First seen exposed | Last seen exposed | Emptied by |
|---|---|---|---|---|---|
| East Midlands | c72b…0086 |
2022-06-05 | 2022-06-23 | 2026-08-16 | no capture after |
| Stansted | 5969…ccf7 |
2022-06-16 | 2022-06-28 | 2026-08-23 | 2026-08-25 20:48 |
| Manchester | 5273…517c |
2022-07-03 | 2022-07-11 | 2026-08-22 | 2026-08-27 21:49 |
Three distinct keys, one per airport, just as FulcrumSec had said, it was "airport-specific".
The keys appear during a rollout in June and July 2022, and then the exact same value is present in every single capture until August 2026. They were never rotated. Not once, in more than four years.
Read the timeline the other way round and it's worse. Anyone who looked at that page source on any day between June 2022 and August 2026 could have taken the key. FulcrumSec just happen to be the ones who told us. There is no way to know, from the outside, who else did, and the honest answer is that MAG can't know either without going back through four years of Iterable API logs, if they even have four years of Iterable API logs.
One detail that does stand out in this, though: Stansted's key went empty by 25 August, at least two days before MAG went public. That sounds like a normal incident response, find it, kill it, then disclose, but it does put a date on when they knew.
For East Midlands, the Internet Archive has no capture of that chunk after 16 August, so I can't precisely date its remediation. It was empty when I looked, though, and that we can prove.
What the key could actually do
Having been a MAG customer many times myself, I'm in the breach many times over, as confirmed by HIBP, so I've had these API keys in my browser and sent these exact requests many times before! All you had to do was visit the right page and your browser would send the request as intended.
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: 527snip17c" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: 596snipcf7" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}
scott@Gaming-Rig:~/code$ curl -s "https://api.iterable.com/api/users/mag%40scotthelme.co.uk" -H "Api-Key: c72snip086" | jq .
{
"msg": "Disabled API key",
"code": "Unauthorized",
"params": {
"ip": "snip",
"endpoint": "/api/users/mag%40scotthelme.co.uk"
}
}I'm glad to see that the keys are disabled at least, it means I don't have to reach out to Iterable to disclose and that the breach did stop when the keys were pulled from the production site.
You didn't need curl to test it anyway. The site's own code calls GET /api/users/{email} with this key and reads data.user.userId off the response. That path returns a user's profile. So the key had, at minimum, read access to customer records by email address.
Given that, the rest follows without any further cleverness. Feed it a list of addresses and you're enumerating a customer database over HTTPS from a coffee shop. FulcrumSec described roughly 86GB compressed, extracting to around 640GB, including a 21.5GB Manchester customer export and about 200,000 records covering upcoming travel in 2026 with dates, times and booking references tied to names.
That last category is the one that actually worries me. Email addresses leak and life goes on, sadly it's a daily thing now. "This named person, with this car registration, is flying on this date" is a different kind of breach, though, and to their limited credit FulcrumSec appeared to recognise it, saying they might redact forward travel records.
But going back to that previous point, something didn't sit right with me. I use custom email addresses per service, and the MAG breach was no different: mag@scotthelme.co.uk
How on Earth did they enumerate that?
It wasn't email enumeration
That question was nagging at me, so I dug deeper. The call in MAG's code looks up one person at a time:
GET /api/users/{email}
So how do you get to 8.8 million records? You'd need the email addresses first, and you can't guess them all. I know I can't be the only person using service-specific aliases like I do.
Turns out it's easy, you don't enumerate. You export!
GET /api/export/data.csv?dataTypeName=user&range=All
dataTypeName is the only required parameter. One request, the entire user table. There's a JSON variant, and a list-walking route via /api/lists into /api/lists/getUsers, which returns every address on a list without pagination.
dataTypeName accepts 51 values. Among them:
| Value | What it returns |
|---|---|
user |
every customer profile |
purchase |
transaction history |
customEvent |
whatever MAG defined — bookings, parking |
emailSend |
every message sent to you |
emailOpen / emailClick |
IP address and user agent, per open |
That last row explains something that puzzled me in the Have I Been Pwned entry for this breach: alongside names and vehicle registrations, the compromised data classes include IP addresses and browser user agent strings. Those didn't come from the website. They came from tracking pixels in marketing emails, exported by data type.
And it could write
The same global auth covers the destructive half of the API too:
DELETE /api/users/{email} delete a customer
DELETE /api/lists/{listId} delete a list
POST /api/users/forget GDPR erasure
POST /api/users/bulkUpdate mass-rewrite profiles
POST /api/users/updateEmail change someone's address
There's no indication FulcrumSec did any of this, and I'm glad, but they could have just nuked everything from orbit and MAG would have been really screwed. For four whole years, the capability to delete Manchester Airports Group's database was a view-source away. This wasn't only a confidentiality exposure. It was a colossal integrity and availability exposure too, and MAG just got lucky. How do they now trust any of the data that remains in the database?
How can I be sure?
Because looking at the published data, my own records gave it away. Buried in a few hundred fields of parking bookings and Fast Track purchases were these:
"itblInternal.regionCode": "GB",
"itblInternal.isAnonymousUser": false,
"itblInternal.isUnknownUser": false,
"itblInternal.phoneType": "MOBILE",
"itblInternal.phoneCountry": "GB",
"itblInternal.emailDomain": "scotthelme.co.uk",
"itblDS.brandAffinityLabel": "negative",
"signupSource": "UpdateSubscriptionsAPI"
itbl is Iterable's own system namespace. Every one of those values is something Iterable derives rather than something a customer supplies, my phone number parsed into a type and a country, the domain split off my address, a region code inferred. itblDS is Iterable Data Science, and Brand Affinity is a machine-learning score their platform generates. Apparently mine is negative, which feels fair.
Nothing in a car park booking produces a machine-learning affinity score. That field was written by Iterable, which means this record passed through Iterable. Whatever was taken carried Iterable's derived fields, Iterable's list and channel IDs, and Iterable's export flattening. And the group who took it said they used Iterable credentials from client-side JavaScript, which is where three of them had been sitting for four years, and which MAG revoked in the days before disclosing.
signupSource names the API method that created the record: POST /api/users/updateSubscriptions.
The record also carries Iterable's own subscription state, emailListIds, unsubscribedChannelIds, subscribedMessageTypeIds, which appear in exactly two places: the response from GET /api/users/{email}, and the output of the user export.
Even the shape is a tell. Every nested value appears twice, once as an object and once flattened into a dotted key:
"cip.lastBooking.parking.vehicleReg": "HE23LME",
"cip.lastBooking.parking": { "vehicleReg": "HE23LME", ... }
That's what Iterable's export produces when it flattens dataFields into columns while keeping the original object. So the data left Iterable. Now, which type of key?
Iterable publish their full OpenAPI specification, unauthenticated, at https://api.iterable.com/api-docs. The security block is global, every endpoint takes the same Api-Key header. But each endpoint is also tagged with the key types permitted to call it, in an extension called x-iterable-api-key-types:
POST /api/events/track Server-side, JavaScript, Mobile, Web
POST /api/users/update Server-side, JavaScript, Mobile, Web
GET /api/users/{email} Server-side
GET /api/export/data.csv Server-side
GET /api/lists/getUsers Server-side
Of 131 endpoints, 98 are server-side only. Twelve accept a browser key.
At first, I thought the code proves the key class: GET /api/users/{email} is server-side only, MAG's page called it, therefore server-side key, right? But look at that call again, it ends in .catch(e => { console.warn(e) }), and the IterableUID it writes is read by nothing. With a browser-scoped key the event tracking would still have worked, the profile lookup would have 401'd into a swallowed promise, and the site would have continued. It could have been failing silently since 2022 and nobody would have known.
The export is what settles it. Producing that record requires the user export or the list endpoints, and both are server-side only. So a server-side key was used. The only credentials in play were three server-side keys published in MAG's own JavaScript, the ones FulcrumSec named as their route in, and the ones MAG revoked, all three, in the days before going public.
Iterable's fingerprints are on the data, and Iterable's spec says what key type was required to access that data. I'm confident that, on balance, this is most likely how the data was stolen.
So what was this API key actually used for?
I went looking for the code that needed this key, expecting something substantial. It's 1,119 bytes, lazy-loaded from /_next/static/chunks/95707.a17f18ff1a88c71f.js:
useEffect(() => {
const email = new URLSearchParams(location.search).get("email");
const userId = sessionStorage.getItem("IterableUID")
|| new URLSearchParams(location.search).get("userId");
const payload = {
eventName: props.eventName || "pageView",
createdAt: Date.now(),
dataFields: { pageUrl: props.pageUrl || location.href.split("?")[0] }
};
if (email) payload.email = email;
if (userId) payload.userId = userId;
if (props.templateId) payload.templateId = props.templateId;
if (props.campaignId) payload.campaignId = props.campaignId;
if (email || userId) trackEvent(payload); // POST /api/events/track
if (email) lookupUser(email); // GET /api/users/{email}
}, []);That's it. That's the whole reason.
Iterable's marketing emails link back to the site with the recipient's address sitting in the query string, ?email=you@example.com&userId=.... This component reads it, fires a pageView event back to Iterable, and the campaign gets credit for your visit. Attribution. That's the job.
And the second call, the dangerous one, GET /api/users/{email}, the arbitrary profile read? It writes the result into sessionStorage under IterableUID. I grepped every chunk on the site looking for whatever consumed that value. Nothing does, not one line reads it back.
So a credential with access to all but ten of 131 endpoints, including a full database export and the ability to delete customers, was published to every visitor of three airport websites for over four years, so that a marketing email could get attribution credit for a page view. And half of what it did was populate a cache that nothing reads.
This is not a MAG problem
It would be easy to file this under "airport group made silly mistake", but it isn't that.
The pattern is: a marketing or analytics platform gives you an API key, the integration guide shows you a fetch call, and it works. It works in dev, it works in staging, it works in prod. Nothing fails. No error appears. No test goes red. The application behaves perfectly while a credential with server-side authority sits in a file that every visitor downloads.
And look at what the browser was actually doing with it. I searched every chunk the site loads, and every archived bundle back to 2022, for any sign of the valuable data being sent from the page including vehicle registrations, booking references, travel dates. Nothing. Not one field, ever. The only thing the browser ever wrote to Iterable was this:
{ email, eventName, dataFields: { phoneNumber, airportCode } }
A newsletter signup. Your email, your phone number, and which airport you're a customer of.
Everything that made this breach worth 640GB, the parking history, the registrations, and the forward travel, was put into Iterable by a server-side sync from MAG's booking platform. That integration was fine. It was never exposed.
So the cause of this whole issue is clear, and it's worse than "they leaked a key": email click tracking was given a credential scoped to the entire customer database, in order to record a click. The read authority and the write requirement were four orders of magnitude apart, and nothing in the toolchain notices a mismatch like that.
And there is no natural moment at which anyone notices. Secret scanning runs against your repository, and this key probably was in a config file or a CI variable, which is exactly where it's supposed to be. The leak happens at build time, when it gets baked into a bundle. A pen test scopes your login and your booking flow, not the twelfth webpack chunk. A WAF sees nothing, because there's no malicious request to your origin; the traffic goes straight from the victim's browser to api.iterable.com and never touches your infrastructure at all.
Which is the part I find most uncomfortable. This vector never touched MAG's servers. None of it would appear in a log MAG owns. The entire incident happened in other people's browsers and on somebody else's API, using a credential they published themselves.
The uncomfortable detail is that this one was avoidable by reading the manual. Iterable's documentation carries it in a box marked WARNING:

"Never embed a server-side API key in client-side code (whether JavaScript, a mobile application, or otherwise), since they can be used to access project data. Use these server-side API keys only when making API calls from your servers."
They issue client-side key types that reach only the handful of endpoints front-end code actually needs, and JWT authentication is on by default when you create one. It's been mandatory for new organisations since 30 August 2022.
The keys in these bundles landed in June and July 2022. They missed that cutoff by weeks, and then nobody looked again for four years.
I've spent years arguing that the browser is a part of your attack surface that most organisations just don't monitor. This is that argument with 8.8 million records attached to it.
What to actually do about it
There are a few important steps you can take right now, and considerations you take forwards:
- Grep your own bundles today. Not your repo, your built, deployed JavaScript. Pull every script your production pages load and search for
api_key,apiKey,Api-Key,token,secret, and 32-character hex strings. This takes an afternoon and it is the highest-value hour of work in this entire post. - Assume anything ever shipped to a browser is public forever. Rotating the key fixes the future. It does not remove it from the Internet Archive, from someone's
curlhistory, or from a threat actor's notes. Every one of these three keys is still sitting in a public archive right now. - Move the call server-side. If your browser needs data from Iterable, Klaviyo, Braze or anything like them, it should ask your backend, which holds the credential and enforces who can ask for what. That's a small proxy endpoint, not an architecture project.
- Use the key type the vendor built for browsers. Iterable issue five, and the client-side ones reach a handful of endpoints instead of all of them. A leaked browser key can act as one person. A leaked server key can export everyone. Same header, same integration effort, completely different blast radius.
- Monitor what your pages actually load and where they send data. You cannot review ninety content-hashed chunks by hand every deploy. Something has to watch the scripts on your pages and the destinations they talk to, and tell you when either changes.
That last one is the reason I do what I do, so yes I'm a little biased, but it is exactly what Report URI was built to do.
The uncomfortable truth
While confirming the Iterable key is gone, I looked at the rest of the same configuration object that still ships to browsers today.
It is not empty. There are several other credential-shaped values in it, including one paired with an AWS API Gateway endpoint.
I'm not publishing those, but they're as public as the others, and I'm not sure a couple of those should be public. But it does illustrate the point better than anything else I could write: the Iterable key was removed because an attacker forced the issue and 8.8 million people paid for it. The ones sitting next to it are still there, so, has anybody looked?
Want the people who did this on your team?
Everything in this post came from reading JavaScript that three different airports published to the world. No exploit, no access, no privileged information. A key sitting in the seventeenth of ninety chunk files, and four years of archived copies to date it against.
That's the uncomfortable bit, this was discoverable the entire time. It was discoverable in 2022, and in 2023, and on every single deploy until August 2026. Nobody looked, because looking means reading ninety content-hashed bundles after every release, and nobody really does that.
So by all means, go and grep your bundles. You should. But understand what a grep gives you: a snapshot of today. Run it in May 2022 against Manchester Airport and you'd have found nothing at all, the key landed in July 2022. The exposure isn't a state you can check once. It's a thing that arrives on a Tuesday in a chunk nobody reviewed, and then stays for four years.

That's the problem Report URI was built for. Not reporting on it after someone else tells you, watching the scripts your pages actually load, and the destinations they actually send data to, and telling you the moment either one changes. If MAG had been watching, the question wouldn't have been "did anyone take the key". It would have been "why is our booking site talking to api.iterable.com from the browser?", and it would have been asked in July 2022, not by an extortion group in August 2026.
Your website is running code right now. Do you know what code it's running?