6 min read

A Winner Every Time: The Uber Promo Where the Prize Pool Shipped to the Client

A post-mortem on an expired Uber Eats promo microsite where the bigger failure wasn't the wheel logic - it was embedding 20,000 promo-code entries directly in client-side JavaScript.

Jared Hass
Jared HassDeveloper and product designer

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:

txt
https://ntsqg3-626-v2.pages.dev/

If you want the exact files referenced below:

  • wheel.js: https://ntsqg3-626-v2.pages.dev/objects/wheel.js
  • cta.js: https://ntsqg3-626-v2.pages.dev/objects/cta.js
  • string.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:00 through 6/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.

Video: losing the game, then opening `cta.js` and still seeing the entire prize mapping in the shipped client.

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:

js
Phaser.Math.Between = () => 55;

Why 55?

  • The wheel calls Phaser.Math.Between(1, 100) to generate chance.
  • The win branch is chance >= 50 && chance <= 60.
  • That window is 11 integer values (50..60), so the intended win rate is 11%.
  • Returning 55 makes the win branch inevitable, and it also feeds the angle selection.
Video: playing normally, then forcing a win with `Phaser.Math.Between = () => 55;`.

wheel.js: the win gate lived in the browser#

Here's the shipped wheel logic (materially identical to production; formatting preserved):

objects/wheel.jsjs
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:

js
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):

objects/cta.jsjs
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:

string.jsjs
"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.

© 2026 Jared HassLast updated Nov 2026

Set in Inter. Built with React and Tailwind, served by Cloudflare.