Over the last year, one of the security patterns that keeps showing up in "modern" builds is not some exotic exploit chain - it's the simplest one:
The browser is being treated like an authority.
This write-up is a post-mortem on an older Uber Eats promotional microsite. The promotion ended in late June 2025. The codes are dead. The page itself is still reachable here:
https://ntsqg3-626-v2.pages.dev/
If you want the exact files referenced below:
wheel.js:https://ntsqg3-626-v2.pages.dev/objects/wheel.jscta.js:https://ntsqg3-626-v2.pages.dev/objects/cta.jsstring.js:https://ntsqg3-626-v2.pages.dev/string.js
This is a security write-up, not a "gotcha." The point is to document a failure mode that keeps repeating: campaign engineering that assumes nobody will look.
And the internet always looks.
The bigger failure wasn't the wheel - it was the prizes#
Yes, the win gate lived in client-side JavaScript. That's bad.
But the larger failure was more basic:
They shipped the prize inventory to the client.
In objects/cta.js, the client embeds a giant timestamp-to-code mapping.
Concrete numbers from the shipped file:
- 20,000 timestamp keys (15-second windows)
- 20,000 unique promo codes (
nflxsg****) - Keys span
6/25/2025 0:00:00through6/28/2025 23:59:45
That's not "UI logic." That's distribution.
That is $912,000 worth of prizes.
If the prize pool lives in the bundle, then "security" is just an agreement between the developer and the user to not open DevTools.
The one-liner that turns 11% into 100%#
The win gate was built on Phaser.Math.Between - which means the "randomness" was just a function call in your browser tab.
This single line was enough to force a win:
Phaser.Math.Between = () => 55;
Why 55?
- The wheel calls
Phaser.Math.Between(1, 100)to generatechance. - The win branch is
chance >= 50 && chance <= 60. - That window is 11 integer values (50..60), so the intended win rate is 11%.
- Returning
55makes the win branch inevitable, and it also feeds the angle selection.
wheel.js: the win gate lived in the browser#
Here's the shipped wheel logic (materially identical to production; formatting preserved):
spinWheel() {
let baseSpins = 6; // number of full 360-degree spins for effect
let selectedAngle;
let chance = Phaser.Math.Between(1, 100);
console.log(chance);
if (chance >= 50 && chance <= 60) {
// Land on a winning sector
let winningSectors = [
Phaser.Math.Between(45, 89), // Sector 1
Phaser.Math.Between(125, 169) // Sector 5
];
selectedAngle = Phaser.Utils.Array.GetRandom(winningSectors);
} else {
// Land on any angle EXCLUDING winning sectors
do {
selectedAngle = Phaser.Math.Between(0, 359);
} while (
(selectedAngle >= 45 && selectedAngle <= 89) ||
(selectedAngle >= 125 && selectedAngle <= 169)
);
}
let totalAngle = baseSpins * 360 + selectedAngle;
this.scene.tweens.add({
targets: this.wheel,
angle: totalAngle,
duration: 3000,
ease: 'Cubic.easeOut',
onComplete: () => {
let finalAngle = this.wheel.angle % 360;
let sector = Math.floor(finalAngle / 45);
if (sector == 1 || sector == -3) {
this.scene.userWon = true;
} else {
this.scene.userWon = false;
}
this.scene.cta.show();
}
});
}This is the core mistake: the browser decides whether you won.
Once that decision is client-owned, the "game" is just animation.
cta.js: hard-coded inventory + deterministic selection#
The call-to-action flow was:
- win screen shows
- client picks a code from a local mapping
- client deep-links the Uber Eats app with that code
The file even logs the generated key used to pick codes:
console.log("🔑 Current key generated:", nowKey);The selection logic is entirely client-side. It generates a timestamp string, then scans the embedded mapping and picks the first code whose timestamp is within 0..14 seconds of "now".
Below is the key section, trimmed for readability (the shipped file is minified):
click() {
const windows = [{
"6/26/2025 0:00:00": "nflxsg6j5b",
"6/26/2025 0:00:15": "nflxsg6j5b",
// <CUT FOR BREVITY>
"6/25/2025 13:16:45": "nflxsg6j16u8u",
"6/25/2025 13:17:00": "nflxsg6j16ub3",
"6/25/2025 13:19:45": "nflxsg6j16v61",
"6/28/2025 23:59:45": "nflxsg6j111by"
}];
const nowKey = this.getTimeString();
const now = new Date(nowKey);
let promoCode = null;
console.log("🔑 Current key generated:", nowKey);
for (const windowMap of windows) {
for (const [ts, code] of Object.entries(windowMap)) {
const deltaSeconds = (now - new Date(ts)) / 1000;
if (deltaSeconds >= 0 && deltaSeconds <= 14) {
promoCode = code;
break;
}
}
if (promoCode) break;
}
if (promoCode) {
const deeplink = 'ubereats://promo/apply?promoCode=' + promoCode;
window.open(deeplink, '_blank');
} else {
alert("Sorry, no active offer at this time.");
}
}Even if the UI tries to hide the CTA on a loss, the inventory is still shipped. Anyone can fetch /objects/cta.js and read the whole mapping without "winning" anything.
And to make the point sharper: the offer amount was hard-coded too:
"offer": "$45.60 off"
If you take that UI-stated value at face value, then 20,000 unique codes at $45.60 off is roughly $912k of promo value embedded into a client-side bundle.
Why this keeps happening#
Promotional microsites tend to be built under incentives that punish best practices:
- ship dates are non-negotiable
- deployments want to be static
- backends are treated as scope creep
- anything that slows launch gets cut
So the work gravitates toward the easiest place to ship logic: the client.
But "easy to ship" and "safe to ship" are not synonyms.
When the browser is responsible for:
- deciding outcomes
- selecting prizes
you have handed control to the one place the user owns completely.
Recommendations (the version that survives contact with reality)#
If you want this kind of campaign to be robust:
- Server decides win/lose. The client submits an attempt; the server returns an outcome.
- Server redeems prizes. The client never receives the inventory.
- Use signed, short-lived claims. Bind them to a session/account and make them one-time.
- Treat the client as hostile. Not because users are bad - because browsers are editable.
Closing thought#
Client-side win logic is the part people laugh at.
Shipping the prize pool to the client is the part that matters.
Because at that point, the giveaway isn't protected by engineering. It's protected by the assumption that nobody will look.
And the internet is very good at looking.