One OTP To Rule Them All: How Uber Bangalore Redesigned Ride Verification


The Quiet UX Change Nobody Wrote a Press Release About

If you booked an Uber in Bangalore last week, you might have noticed something. The OTP screen looks the same. The driver still asks for it. But there’s no “Regenerate OTP” button anymore. And if you cancel and re-book the same trip — well, you can’t really, because the trip is a new entity. But the behaviour of the OTP has fundamentally changed.

Uber has quietly moved Bangalore to a single, non-regenerating OTP per ride model. This is the same model Rapido has been running for years. One OTP. One ride. No regeneration. No “wait, which OTP did you get?” confusion. No edge cases where the driver’s app shows 4729 while the rider’s SMS shows 8821 because of some race condition in delivery.

On the surface, this looks like a minor UX simplification. Underneath, it’s a substantial backend redesign — and counterintuitively, it’s a security upgrade, not a downgrade.

Let me walk through why.

The Old Model: TOTP-ish, with Knobs

Uber’s old OTP scheme — and most ride-hailing OTP schemes globally — behaved roughly like a soft TOTP. Time-bound. Regenerable. Bound to a session, but loosely.

Here’s roughly what the old flow looked like:

  1. Rider books a ride. Backend generates an OTP, persists it against trip_id, ships it to the rider’s device over a secure channel (app payload + SMS fallback).
  2. Driver accepts. The driver’s app pulls the OTP from the trip object via an authenticated API call when the trip enters the ARRIVING state.
  3. If something went wrong — SMS didn’t deliver, app crashed, rider didn’t see it — the rider could tap Regenerate OTP. This invalidated the old OTP, generated a new one, and pushed it down both channels.
  4. Driver verifies OTP at pickup → trip starts.

The “regenerate” button is the interesting part. It looks innocent. It’s actually the attack surface.

sequenceDiagram
    autonumber
    participant R as Rider
    participant U as Uber Backend
    participant F as "Uber Support"<br/>(Scammer)
    R->>U: Book ride
    U-->>R: OTP_1 (SMS + push)
    Note over R,F: Phishing call lands during pickup window
    F->>R: "Your trip didn't register —<br/>please tap Regenerate and<br/>read out the new code"
    R->>U: POST /trip/{id}/otp/regenerate
    U-->>R: OTP_2 (OTP_1 invalidated)
    R->>F: Reads OTP_2 aloud
    F->>U: Verifies parallel fraudulent<br/>action on hijacked account

The arrow that doesn’t exist in any architecture diagram — the one from rider to scammer — is the whole problem. Regenerate is the API call that turns a stale OTP into a fresh weaponisable one.

Why “Regenerate” Is a Bad Primitive

The regenerate primitive sounds defensive. Lost the OTP? Just make a new one. But think about what it actually creates:

  • A social engineering vector: A scammer impersonating “Uber support” can pressure a rider into pressing “Regenerate” and reading out the new code over the phone. The rider thinks they’re verifying themselves to Uber. They’re actually verifying a fraudster’s parallel trip on a hijacked account.
  • A driver-side fraud vector: A driver could, in collusion with someone else, force regenerate flows by stalling and asking the rider to “try again,” exploiting the asynchronous propagation window between when a new OTP is minted and when it lands on both devices.
  • A support-load multiplier: Every regeneration is a support ticket waiting to happen. “Why is my OTP different now?” “The driver says my OTP is wrong.” Customer service teams hate this.
  • A correctness nightmare: If your OTP can change mid-trip-state, every consumer of the OTP — SMS service, push service, driver app, rider app, voice-call IVR fallback, support tooling — needs to deal with cache invalidation. And cache invalidation, as the saying goes, is one of the two hard problems in computer science.

The cleanest fix is to remove the primitive entirely. That’s what the new model does.

The New Model: One OTP, Bound to the Trip Entity

The new mental model is simple: the OTP is a property of the trip, not a token issued in a session. It’s generated once, at trip creation, and it lives and dies with the trip.

Think of it less like a TOTP and more like a non-rotating shared secret scoped to a single resource — closer in spirit to a single-use bearer token than to traditional 2FA.

Here’s roughly what the flow looks like now:

flowchart TD
    A[Rider books ride] --> B["TripService.createTrip()"]
    B --> C["OTPService.mint()"]
    C --> D[("Trip row<br/>otp_hash + Enc(otp)<br/>at rest")]
    D --> E[Rider app pulls trip<br/>OTP delivered in payload + SMS]
    D --> F[Driver matched]
    F --> G[Driver app pulls trip on<br/>ARRIVING state — OTP in UI]
    E --> H[Pickup verification]
    G --> H
    H --> I{Server compares<br/>constant-time hash}
    I -->|match| J[Trip starts<br/>OTP marked CONSUMED]
    I -->|mismatch| K[Increment otp_attempts]
    K -->|attempts &lt; 5| H
    K -->|attempts ≥ 5| L[Flag for review]

    style D fill:#eef,stroke:#669
    style J fill:#bfb,stroke:#363
    style L fill:#fbb,stroke:#933

That’s the whole thing. No regenerate endpoint. No state machine for the OTP itself. The OTP doesn’t have a lifecycle independent of the trip.

Generation: Not TOTP, Just Cryptographic Random

People assume OTPs are HOTP/TOTP under the hood because that’s what “OTP” means in the auth world. They’re not, for ride-hailing.

For a ride OTP, you don’t need time-based rotation. You need:

  • Unpredictability: 4 digits = 10,000 possible values. That’s tiny. So unpredictability matters — you want each value drawn from CSPRNG, not derived from anything guessable (like trip_id % 10000, which I have actually seen in a production codebase from a smaller competitor years ago).
  • Uniqueness within the active trip set, per driver: If driver D has two trips queued back-to-back, the OTPs shouldn’t collide. Easy with 4 digits and low concurrency per driver.
  • Constant-time comparison at verification: To prevent timing side-channels.

So mint looks roughly like:

def mint_otp(trip_id: str) -> str:
    # 4-digit OTP, drawn from CSPRNG
    n = secrets.randbelow(10000)
    otp = f"{n:04d}"

    # Persist encrypted-at-rest, against trip
    trip_store.set_otp(
        trip_id=trip_id,
        otp_hash=hmac_sha256(server_key, otp),  # store hash, not plaintext
        otp_ciphertext=encrypt(otp, kms_key),   # so we can still surface it to driver UI
    )
    return otp

Two things worth calling out:

  1. The OTP is stored as both a keyed hash (for verification) and a ciphertext (for retrieval). You need retrieval because the driver app, on the legitimate path, asks the server “what’s the OTP for this trip?” — the server can’t recompute it from a hash. The ciphertext is encrypted with a KMS-managed key, so a database leak alone doesn’t expose live OTPs.
  2. The hash is keyed (HMAC), not plain SHA-256. Four-digit OTPs are trivially rainbow-tableable otherwise — there are only 10,000 of them. Keying the hash with a server-side secret means an attacker who exfiltrates the DB still can’t enumerate.

Verification: Constant Time, Rate Limited, Bound

Verification is the boring-but-critical part:

def verify_otp(trip_id: str, driver_id: str, submitted: str) -> bool:
    trip = trip_store.get(trip_id)

    # Bind to driver
    if trip.assigned_driver_id != driver_id:
        return False

    # State gate: only verifiable in ARRIVING / AT_PICKUP
    if trip.state not in {ARRIVING, AT_PICKUP}:
        return False

    # Rate limit per trip
    if trip.otp_attempts >= MAX_ATTEMPTS:  # typically 5
        flag_for_review(trip_id)
        return False
    trip_store.increment_otp_attempts(trip_id)

    expected_hash = trip.otp_hash
    submitted_hash = hmac_sha256(server_key, submitted)

    # Constant-time compare
    return hmac.compare_digest(expected_hash, submitted_hash)

The bindings here are doing the heavy lifting:

  • Bound to driver_id: Even if a malicious actor obtains a valid trip OTP, they can’t verify against any random driver. Only the assigned driver’s verification call will pass.
  • Bound to trip state: An OTP can’t be verified before the driver is ARRIVING or after the trip has started. The valid verification window is typically a few minutes long, not the whole lifetime of the trip object.
  • Bound to attempt count: After 5 failures, the trip is escalated. Brute-forcing a 4-digit OTP needs ~5,000 tries on average; with a cap of 5, the probability of a random hit is 5/10,000 = 0.05%.
  • Constant-time compare: Prevents an attacker who controls the driver-side input from learning OTP digits via timing.

The full lifecycle, end to end:

sequenceDiagram
    autonumber
    participant RA as Rider App
    participant TS as Trip Service
    participant OS as OTP Service
    participant DB as Trip Store
    participant DA as Driver App
    RA->>TS: createTrip()
    TS->>OS: mint(trip_id)
    OS->>OS: secrets.randbelow(10000)
    OS->>DB: put { hmac(otp), enc(otp) }
    OS-->>TS: otp (plaintext)
    TS-->>RA: trip payload incl. OTP
    Note over RA,DA: SMS + push fan out asynchronously
    Note over DA: Driver matched, state → ARRIVING
    DA->>TS: GET /trip/{id}
    TS->>DB: read trip
    DB-->>TS: trip + enc(otp)
    TS-->>DA: trip + decrypted OTP for display
    Note over RA,DA: Rider reads OTP in app or SMS, tells driver
    DA->>TS: verifyOtp(trip_id, driver_id, submitted)
    TS->>TS: bind checks: driver_id, state, attempts, geofence
    TS->>TS: hmac.compare_digest()
    TS-->>DA: ok
    TS->>DB: state → IN_TRIP, OTP CONSUMED

Notice that the OTP never crosses a network boundary as plaintext after generation, except down the rider’s and driver’s authenticated channels. SMS is the only side channel — and it’s redundant, not authoritative.

The Hard Part: Delivery Consistency

Generation and verification are textbook. The genuinely hard engineering problem in this migration is making sure both sides see the same OTP, always, with no race conditions, across SMS gateways, push channels, app state, and network partitions.

In the old “regenerate” world, this was easier — if the channels disagreed, you just regenerated and forced a re-sync. That’s the escape hatch. Take away the escape hatch and your delivery pipeline has to actually be correct.

Source of Truth: The Trip Object

The trip row in the primary store (likely Cassandra or a sharded MySQL/Postgres setup at Uber scale, fronted by a strongly-consistent trip service) is the only source of truth for the OTP. Every client that displays the OTP — rider app, driver app, support dashboard — fetches it from there.

Crucially:

  • SMS is a notification, not a source of truth. The SMS gateway is best-effort. If it fails, the rider can always see the OTP in their app by pulling the trip object. The system doesn’t depend on SMS landing.
  • Push notifications are a notification, not a source of truth. Same logic — push is a UX accelerator, not a correctness primitive.
  • The driver app fetches on state transition. When the driver’s app transitions the trip into ARRIVING, it pulls the latest trip snapshot. The OTP is included in that payload. There is no separate “OTP fetch” call.

This means the delivery pipeline doesn’t need to be transactional with OTP generation. The OTP is committed to the trip row first. Notifications fan out after. If the fanout fails, no problem — clients can pull on demand.

Why This Couldn’t Work With Regeneration

If OTPs were regenerable, this architecture falls apart. You’d need to either:

  • Synchronously invalidate caches on regen — which means a distributed cache-bust across every channel before the new OTP is considered valid. Expensive and fragile.
  • Use a TOTP-like rotation with both sides computing from a shared seed — which is even more complex because now you need clock sync between phones, and a 30-second window is too short for a real-world pickup.

By eliminating regeneration, the OTP becomes immutable for the trip’s lifetime. Immutable values are trivially cacheable. You can stamp the OTP into the trip payload at the edge, cache it at the CDN layer for the driver fetch, and never worry about staleness.

”But Isn’t a Non-Rotating OTP Less Secure?”

This is the question every security-minded reader asks. The instinct is: rotation = more security. Non-rotating = stale credential = bad.

That intuition is correct for long-lived secrets. It’s wrong here, for several reasons:

1. The OTP Isn’t a Credential

A credential authenticates an identity over time. A ride OTP authenticates a single physical transaction at a specific moment in space and time. Rotation has no meaning when the secret is consumed once and discarded.

2. The Threat Model Doesn’t Include “OTP Theft Over Time”

What are we actually defending against? Three things:

  • Wrong driver / wrong rider pairing — someone gets into the wrong car. Solved by binding the OTP to (trip_id, driver_id).
  • Brute force at the pickup point — solved by rate-limiting attempts to 5.
  • Social engineering / phishing — solved by removing the regenerate button, because that was the lever phishers pulled.

None of these are addressed by rotation. In fact, rotation makes phishing easier — the attacker has a “natural” reason to ask for a new code (“the old one didn’t work, try regenerating”).

3. The Bound Window Is Tiny

The OTP is only valid during the ARRIVING and AT_PICKUP states. That’s typically a 5–10 minute window in dense urban areas. After the trip starts, the OTP is dead — even if leaked, it can’t be used. Compare this to a TOTP, which is valid for 30 seconds anywhere in the world once you have the seed. The state-bound model is actually narrower than time-bound rotation.

4. Defence in Depth Comes From Bindings, Not Rotation

The real security comes from the bindings layered on top of the OTP:

BindingWhat it prevents
Bound to trip_idOTP from trip A can’t unlock trip B
Bound to driver_idStolen OTP can’t be redeemed by a different driver
Bound to trip stateOTP can’t be used outside the pickup window
Bound to attempt countBrute force capped at 5 tries
Constant-time compareNo timing side-channel
Server-side audit logAll verification attempts logged, anomalies flagged
Geofence on driverDriver must be within N metres of pickup to verify

Visualised as a funnel, every verification request gets squeezed through these gates in sequence:

flowchart TD
    S[Driver submits OTP] --> B1{Trip exists?}
    B1 -->|no| X[Reject]
    B1 -->|yes| B2{driver_id matches<br/>trip.assigned_driver_id?}
    B2 -->|no| X
    B2 -->|yes| B3{State in<br/>ARRIVING / AT_PICKUP?}
    B3 -->|no| X
    B3 -->|yes| B4{Attempts &lt; 5?}
    B4 -->|no| F[Flag for review]
    B4 -->|yes| B5{Driver GPS<br/>within geofence?}
    B5 -->|no| X
    B5 -->|yes| B6{hmac.compare_digest<br/>matches?}
    B6 -->|no| X
    B6 -->|yes| OK[Trip starts]

    style OK fill:#bfb,stroke:#363
    style X fill:#fbb,stroke:#933
    style F fill:#fbb,stroke:#933

That last gate is the underappreciated control. Uber’s verification API almost certainly cross-checks the driver’s reported GPS against the pickup location at the moment of OTP submission. An OTP submitted from 5km away gets rejected regardless of correctness. This is what makes remote OTP phishing essentially useless — even if a scammer convinces a rider to read out their OTP, the scammer can’t physically submit it from the right place.

Why Rapido Got Here First

Rapido has run this model since (roughly) their early bike-taxi days. Two reasons they could ship it earlier than Uber:

  1. Smaller surface area: Fewer markets, fewer regulatory regimes, fewer legacy integrations. No corporate accounts demanding regenerable OTPs because their employees keep losing them. Uber has decades of legacy product surface to migrate.
  2. Different fraud profile: Bike-taxi rides have shorter pickup windows and tighter geofences naturally. The static OTP fits the use case more cleanly.

Uber’s migration is interesting precisely because it’s an admission that the “more configurable = more secure” instinct was wrong. Simpler primitive, harder bindings, smaller blast radius.

The Engineering Lesson

There’s a broader principle here that generalises beyond ride-hailing.

When you have a primitive that “feels safer” because it offers control to the user (regenerate, rotate, refresh), audit who actually uses it. Often, the answer is:

  • Legitimate users: <1%, mostly to recover from transient delivery failures that could be fixed at the delivery layer.
  • Confused users: 5–10%, generating support load.
  • Attackers: a disproportionate fraction of remaining traffic, because the primitive is exactly the lever they need.

Removing the primitive and investing in the failure modes it was patching over (better SMS delivery, better push reliability, better in-app surfacing) is usually the right call. It collapses the state space, eliminates a class of bugs, and pulls the rug out from under a category of social-engineering attacks.

That’s the real story of Uber Bangalore’s single-OTP migration. Not a UX tweak. A deliberate reduction in API surface, in service of correctness and security.

Closing Thoughts

The next time you see a “Regenerate” button anywhere — in an OTP flow, a session token, an API key — ask yourself: is this here because users genuinely need it, or because we couldn’t be bothered to make the underlying delivery reliable?

Often, the button is a confession of an engineering shortcut. And often, attackers know how to read confessions better than your security team does.

Uber’s move to single-OTP is, in some sense, a long-overdue cleanup. Rapido just got there first.