Back to all posts
Published on · by Renaud Deraison

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_ into import.meta.env at build time. Next.js does the same for NEXT_PUBLIC_, and Create React App used REACT_APP_. The prefix is how you ask for the substitution.
  • webpack's DefinePlugin and esbuild's --define replace 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.

A secret's route to the public webbuild environmentAWS_SECRET_…=realbundler inlines ittext substitutiondeployapp.[hash].jsserved publicly200 OK, cacheableanyone readsview-sourceNothing above is an exploit. Below is the only step involving an attacker.valid credentials, valid API callsJul 27, 01:20:16 UTC · 1 h 27 minwhole database + attachments1,500+ charities, readable formatencryption at rest: no effectAWS decrypts for a valid callerRoot cause, timings and scope from Beacon's incident report of 2026-08-12, via The Register and SecurityWeek.
The credential's route to the public web at Beacon. Nothing in this path is a vulnerability, an intrusion, or a bypassed control. Each step is a tool doing what it exists to do.

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_TOKEN here: env vars beat credential_process in 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.

CONVENTIONAL: the secret is in the room with the builddeveloper machineAWS_SECRET_ACCESS_KEY = realSigV4 signed here · bundler reads hereAWS acceptsfrom anywhereany copy of the environmentis a working credentialBROMURE: the secret is never in that roomVM: agent, build, bundlerno key, no secret, no tokensigns with a 40-char fakehost proxy: AWSResignerstrips Authorizationre-signs with the real secretAWS acceptsvia your Mac onlybypass the proxy:InvalidSignatureException
Bromure computes the signature somewhere else. Sending a secret into the machine that runs untrusted code is what makes an environment variable worth stealing; signing outside that machine removes the reason to look.

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:

  1. The proxy refuses the request with HTTP 451, and not one byte reaches the destination.
  2. Bromure pauses the VM on the spot.
  3. 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"