cat posts/2026-04-03-what-a-source-map-can-leak.md

What a source map can leak

· 5 min read · javascript security npm build

{
  "version": 3,
  "file": "app.min.js",
  "sources": ["src/billing/invoice.ts", "src/billing/tax.ts"],
  "sourcesContent": ["// TODO: remove once the new tax engine ships\nexport function total(...", "..."],
  "names": ["total", "lineItems"],
  "mappings": "AAAA,SAASA,..."
}

That’s the top of a source map, slightly simplified, and the fourth line is the one that matters. I’ll come back to it.

On Tuesday morning someone noticed that a release of Claude Code on npm included a .map file alongside the bundled JavaScript, and that the map had the original TypeScript in it. By lunchtime the whole thing had been pulled out and mirrored on GitHub, about 1,900 files and over 500,000 lines by the counts going around, and people were reading through it for unreleased features and internal codenames. Anthropic’s statement called it “a release packaging issue caused by human error, not a security breach”, and said no customer data or credentials were exposed. As far as I can tell that’s accurate. It’s still a company’s entire product source, published by accident through the most ordinary file in a JavaScript build.

I use Claude Code every day, so I read the coverage with more interest than usual. But the reason I’m writing about it is that we ship JavaScript too, and I spent Wednesday checking what our source maps say about us.

What a source map is

When you build a modern JavaScript app, what runs in the browser bears little resemblance to what you wrote. TypeScript becomes JavaScript, hundreds of files become a few bundles, and a minifier renames every variable to one letter and puts it all on one line. When something throws an error in production, the stack trace points at line 1, column 48,213 of app.min.js, which tells you nothing.

A source map is the translation table. It’s a JSON file, and the browser’s developer tools, or your error tracker, use it to turn “line 1, column 48,213” back into “invoice.ts, line 212, in total”. The bundle points to its map with a comment on its last line, //# sourceMappingURL=app.min.js.map, and the browser only fetches the map when someone has developer tools open.

app.min.js line 1, column 9 segment "SAASA" decodes to 9, 0, 0, 9, 0 invoice.ts line 1, column 9, "total" sourcesContent[0]: the whole of invoice.ts every comment, every TODO, every function, as you wrote it optional
The mappings only need the file names and positions. The optional sourcesContent field carries the files themselves, and that's what makes a map a copy of your source.

The mappings field is where the format gets interesting. It’s a long string of segments, separated by commas within a line of the generated file and by semicolons between lines. Each segment is a few numbers, written in a variable-length Base64 encoding to keep them short. SAASA decodes to 9, 0, 0, 9, 0, and they’re all relative to the previous segment: nine columns further along in the generated file, the same source file (index 0 in sources), the same line in it, nine columns further along in that line, and the same entry in names. Because they’re deltas, most of them are tiny, and tiny numbers encode in one character each. I spent a happy half-hour on Wednesday decoding a few segments by hand to check I understood it, which I recommend to no one.

Now the fourth line. sources is a list of file names, and that’s all the mappings need. But a debugger showing you line 212 of invoice.ts needs to show you the file, and it may not be able to fetch it from wherever it lived on the build machine. So the format has an optional sourcesContent field, a list of strings, one per source, holding the complete original text of each file. Most bundlers fill it in by default, because it makes debugging work with no extra setup.

Which means a source map with sourcesContent is a copy of your source code, comments, TODOs and all, in one JSON file. Even without it, sources lists every file path in your project, and names lists your original identifiers.

How they end up public

In a browser app, the usual way is that the bundler writes app.min.js.map next to app.min.js, the deploy uploads the whole build directory, and the map is now on your CDN for anyone who requests it. Nobody decided to publish it.

In an npm package, it’s the package’s list of files. npm decides what goes into the tarball from the files field in package.json, if there is one, which is an allowlist. If there isn’t, it includes everything not excluded by .npmignore, or by .gitignore if there’s no .npmignore, which is a denylist. A denylist fails open. Add a new kind of build artifact, or change where it’s written, and it ships unless someone remembers to exclude it. I don’t know which of these happened on Tuesday, and the explanations going round don’t agree. But every story I’ve heard of this kind is one of those two.

What I checked

Our main web app was fine. Its build generates source maps, uploads them to our error tracker so stack traces are readable, and then deletes them before deploying, and the bundles don’t reference them. Some bundlers call that a “hidden” source map. We set that up years ago and I’d forgotten why, which I suppose is the sign it was a good idea.

One of our internal admin tools was not fine. It’s built by a separate, older pipeline, and its maps were sitting on the CDN next to its bundles, sourcesContent included, publicly fetchable even though the tool itself is behind login. Nothing in there was a secret, because we don’t put secrets in client code, and anything in client JavaScript is readable with or without a map. But the comments were there, including a few I’d rather a customer didn’t read, and so were the names of three features that aren’t announced yet. It was fixed by Wednesday afternoon.

For our two small npm packages, I ran npm pack --dry-run, which lists exactly what would go into the tarball without publishing anything. Both use files already, and neither had a map in it. That’s now a CI step for both, failing the build if a .map file appears in the list.

I’m not against shipping source maps. Plenty of open-source projects ship them on purpose, and they make debugging other people’s code much nicer. What I want is for somebody to make that decision once, on purpose, and for the build to check it every time after that, instead of it being whatever the bundler’s default was on the day someone wrote the pipeline.