The build shipped the key
Beacon CRM's expanded incident report traces the theft of its entire customer database, covering more than 1,500 UK charities, to an AWS access key that its own build process baked into a public JavaScript file. Nobody broke into a developer's machine. A build tool copied an environment variable into an artifact, which is what build tools do. Bromure Agentic Coding's answer is that the variable holds nothing worth copying.
There is no attacker in the first half of this story. A build process copied a secret out of an environment variable into a JavaScript file, and a web server served that file to anyone who asked. The theft, when it came, was a GET request.
Beacon is a CRM used by UK charities to track donors, supporters and volunteers. On August 12 its CTO, David Simpson, published an expanded incident report on a breach the company had first disclosed on August 4. The Register reported it the next day, SecurityWeek followed on August 14, and the root cause is one line:
An AWS access key potentially exposed in public JavaScript build artifacts.
At 01:20:16 UTC on July 27, someone started using that key, and held access for one hour and twenty-seven minutes. Beacon's assessment of what left:
A copy of the database which holds all Beacon customer data, including attachment files, was made and likely downloaded in a readable format by the threat actor.
That covers more than 1,500 charities: supporter names, phone numbers, email addresses, postal addresses, donation records and attachment files. No card or bank details, since Beacon's customers don't store those in it. The ICO reviewed at least one victim charity and found that the charity holds no responsibility for the breach, which is correct and also cold comfort to a fundraising team explaining to its supporters where their home addresses went.
Beacon encrypted the data at rest. That changed nothing. Cybersecurity News makes the point that AWS decrypts on behalf of whoever holds valid credentials. Encryption at rest protects you against someone walking off with a disk, and it has no opinion about a caller with the key.
The part worth staring at
Read the incident report looking for the intrusion and you will not find one.
There was no phishing email, no compromised maintainer, no poisoned dependency
or prompt injection, and no malware on an engineer's laptop. Nothing in this
story bypassed a control, because nobody extracted the credential from
anywhere. Beacon's build process copied a value out of an environment variable
into a bundle, Beacon deployed the bundle as a static asset, and a web server
handed it to every client that requested it, as designed. The exfiltration
channel was a <script> tag.
The mechanism is not exotic. Front-end builds inline environment variables on purpose, because the browser has no environment to read at runtime:
- Vite substitutes any variable prefixed
VITE_intoimport.meta.envat build time. Next.js does the same forNEXT_PUBLIC_, and Create React App usedREACT_APP_. The prefix is how you ask for the substitution. - webpack's
DefinePluginand esbuild's--definereplace a token in your source with a string. They have no notion of which strings are secret, and no way to acquire one. - A source map is a second copy. Server-rendered frameworks add a third route: a value read in code that ends up in a client component gets serialized into the payload.
Each of those copies from the environment of whoever, or whatever, started the build. The environment is the raw material, and the build is a machine for copying it into a file you then publish.
The workstation this starts on
Beacon's breach is a cloud story in its consequences. Its cause sits on a developer workstation, and the workstation is changing.
An agent working a ticket does all of this in a normal afternoon. It adds an
environment variable so a feature can reach a service. It edits
vite.config.ts or next.config.js. It writes the line that reads the
variable, and it picks the module that line lives in, which is the decision
that settles whether the value stays on the server or crosses into the bundle.
It runs npm run build. It commits dist/ when a deploy target wants the
artifact checked in.
None of that requires the agent to make a mistake, and you get no signal if it does. The build succeeds, since a string is a string. The bundle ships as a minified single line, so nothing in the diff catches your eye. The value works, so the feature works and the ticket closes.
Meanwhile the workspace an agent runs in is stocked with credentials, because credentials are what make it useful: cloud keys so it can check a bucket, a GitHub token so it can push a branch, registry and database credentials, a model API key. Each of those is a string sitting in an environment that a build process reads in full.
The Beacon report puts a direct question to that arrangement. Something in your workspace copies your environment into a file, sooner or later. Ask yourself what it gets.
In a Bromure workspace, it gets a region string
Bromure Agentic Coding runs the agent in a disposable Ubuntu VM on Apple's Virtualization framework, with a host-side MITM proxy as its only route to the network. The credential design follows from that: real secrets stay on your Mac, and the VM gets values that look right and are worth nothing.
AWS gets its own handling, because SigV4 never sends the secret. The SDK consumes the secret in the client to compute an HMAC and puts the signature on the wire, so Bromure cannot swap a fake for a real value in transit the way it does for a bearer token. It moves the signing instead.
Start with what a build process would find. Bromure exports AWS_DEFAULT_REGION
and AWS_REGION into the VM and no key, secret or session token at all. The
reasoning sits in the source, in SessionDisk.swift:
We must NOT export
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKENhere: env vars beatcredential_processin the SDK's chain, and they'd also defeat the no-secret-on-disk guarantee (envs leak via/proc,ps -E, and shell history).
That list of leak channels describes process inspection. A bundler reading
process.env belongs on it, and the same decision covers it. No
AWS_SECRET_ACCESS_KEY exists in the environment for a build to inline, so
DefinePlugin substitutes nothing, import.meta.env carries nothing, and
NEXT_PUBLIC_AWS_SECRET_ACCESS_KEY, the Beacon mistake made on purpose,
publishes an empty string.
The SDK still works. ~/.aws/config points at a credential_process helper:
[default]
credential_process = /mnt/bromure-meta/bromure-aws-creds.py
region = eu-west-2
The helper reads one JSON document off a socket. It returns the real
AccessKeyId, which identifies you rather than authenticating you, paired with
a SecretAccessKey that Bromure generates for the session: forty characters
drawn from the alphabet a real AWS secret uses, so boto3, the aws CLI and
Terraform accept it and sign as usual. The signature they produce is bound to
fail.
Then the host fixes it. AWSResigner recognizes any *.amazonaws.com request
coming through the proxy, strips the guest's Authorization header, and
recomputes SigV4 with credentials that exist only in the host process's address
space, adding the real X-Amz-Security-Token when the profile carries STS
material. Your terraform apply works, and the secret never entered the machine
that ran it.
Go around the proxy and AWS answers:
An error occurred (InvalidSignatureException) when calling the
ListBuckets operation: The request signature we calculated does not
match the signature you provided.
The credential fails closed. One that only functions when routed through your Mac cannot be exercised from a stranger's laptop, which is the property Beacon's published key lacked.
The rest of the panel is decoys
AWS is the special case. For the rest, the wire boundary does the work: each credential you configure appears inside the VM as a structure-preserving fake, derived from the real value plus a per-install 32-byte salt through HKDF-SHA256. The real value stays encrypted on your Mac, and the proxy substitutes it onto the request after the bytes have left the VM, only when the request is bound for the host that credential was minted for.
That resolves the whole class of "a tool copied the environment somewhere it shouldn't have gone" the same way, whatever the tool was:
Model and vendor API keys
ANTHROPIC_API_KEY is sk-ant-api03-brm-…. OPENAI_API_KEY is
sk-brm-…, XAI_API_KEY is xai-brm-…. Structure-preserving, so the
CLIs accept them without complaint, and inert anywhere else.
Git, registry and cloud tokens
GH_TOKEN is ghp_ plus 36 characters, GitLab is glpat- plus 20,
DigitalOcean is dop_v1_ plus hex. ~/.git-credentials,
~/.docker/config.json, ~/.kube/config and
~/.config/doctl/config.yaml are all present, all populated, all fake.
A NEXT_PUBLIC_ prefix
Prefix any of those and the bundler does what you told it. The published
artifact carries brm-docker-… or ghp_-shaped filler, and the mistake
costs you a redeploy instead of a disclosure notice.
A committed dist/ or source map
Same answer, and it doesn't depend on anyone noticing. The artifact can sit in a public repository, get indexed, and be scraped by every secret crawler running, and the string they collect authenticates to nothing.
Bromure derives the fakes the same way each time, so a tool that fingerprints its own key, as Claude Code does when it caches a key hash, never sees the credential change between sessions. There is also no switch to forget. The proxy is the VM's only route out, so a request that goes around it carries a placeholder and fails upstream.
The published decoy is a tripwire
Beacon's key sat in a public file for an unknown period before July 27, and the first signal anyone got was a spike in AWS Cost and Usage reports for July 27 and 28, read after the fact.
Bromure's compromise detector watches your credentials rather than the destination's reputation, which is what lets it fire on a host nobody has ever named. An Aho-Corasick automaton built from the workspace's own minted fakes sweeps every outbound request, headers and body. A fake bound for a host outside the scope it was minted for counts as attempted exfiltration, and Bromure responds:
- The proxy refuses the request with HTTP 451, and not one byte reaches the destination.
- Bromure pauses the VM on the spot.
- Bromure raises an alert naming the credential, the host it was minted for, and the host it was observed heading to.
Bromure then marks the workspace compromised, so the next launch requires wiping the VM disk image and the persistent home. Your tokens, SSH keys and settings survive that.
Point the mechanism at the Beacon scenario. A fake minted for one destination turning up in a request to another is the signature, and it doesn't matter how the fake got out: a leaked bundle, a stolen dotfile, a curious stranger with a browser. The first person to try the credential they found announces themselves, into a log you own, at the moment they try it.
The record Beacon says it will never have
Simpson closes the incident report on the limits of what Beacon can establish:
There are things we may never be able to find out about this incident.
Beacon states the limit plainly. The specific objects, the exact destination of the downloads, and definitive attribution of which objects were accessed cannot be determined from available logs. Beacon's conclusion that the whole database left is an inference from the shape of a billing report, a transfer volume in the Cost and Usage data that matches the approximate size of what Beacon stores. That is good forensic work with nothing to work from, and every charity now writing to its supporters is relying on it.
A Bromure workspace produces that record as a byproduct of how it runs. Each request crosses the host proxy, so the proxy writes down what happened:
$ bromure-cli trace ls
HOST METHOD STATUS MS FLAGS
api.anthropic.com POST 200 412 swap×1
s3.eu-west-2.amazonaws.com GET 200 88
registry.npmjs.org GET 200 31
api.github.com POST 201 140 swap×1
trace hostnames lists each distinct host a session contacted, trace summary
aggregates the lot, and trace leaks shows the unmanaged credentials. The Trace
Inspector (⇧⌘I) gives the same view with request bodies, and the Security Log
(Window → Supply Chain Log…) tails supply-chain and 451 decisions as they
happen. Bromure keeps all of it encrypted at rest on your Mac under the vault
master key.
The questions Beacon can only answer by inference are a lookup you run yourself: what did this workspace talk to, when, carrying what, and did anything credential-shaped leave. Against your own data, in the minute you first wonder.
The arrangement that closes the gap
Beacon's engineers did nothing unusual. Putting a credential in an environment variable is the recommended practice, and reading environment variables during a build is what builds do. A whole customer database went through the gap between those two reasonable things, and no amount of care closes it, because care is a hope about attention rather than a control.
The control is arranging things so the copy is worthless. Keep the real credential on the host, hand the workspace a placeholder that satisfies every tool that reads it, sign and substitute outside the machine that runs the code, and keep your own record of each request that leaves. Then a build tool doing its job, an agent making a reasonable-looking edit, and a stranger reading your bundle at leisure all arrive at the same place: a string that means nothing off your Mac.
Sources: The Register, "AWS key exposed in JavaScript may have lit way to Beacon's charity data" (Aug 13, 2026) · SecurityWeek, "Over 1,000 Charities Hit by Beacon CRM Data Breach" (Aug 14, 2026) · Infosecurity Magazine, "Exposed AWS Access Key Linked to Data Breach Affecting 1500+ UK Charities" · Cybersecurity News, "Beacon CRM Confirms Full Database Theft After AWS Access Key Breach"