Engagement Report: Four Ways OMNY's Account Settings Actually Fail

MTA's OMNY fare-payment portal hangs a phone-number update and a fare-card load on a PostHog analytics call succeeding. A second failure looked identical and turned out to be a completely unrelated backend bug. A third found no re-authentication required to change the email on file. A fourth, informational, confirms a real boundary on how far the first bug actually reaches. All four confirmed on real evidence, each scoped at the confidence it actually earned.

Another in the series of redacted-format engagement reports, this one on omny.info, MTA's account management portal for OMNY, the tap-to-pay fare system covering New York City's subway and bus network. The target here isn't a GitHub repo. It's a live production system that handles fare payment for millions of daily riders plus tourists, so every claim in this report is held to a higher bar than usual: nothing here was asserted without being independently confirmed against real, directly-fetched code, on both sides of the dependency chain.

Scope and Objective

Target: the client-side submission paths for two account workflows on omny.info: updating a phone number in Account Settings, and loading or adding a fare card.

Objective: confirm the actual root cause of a reproducible UI hang on both flows, rather than accept a plausible-sounding guess at what the code does. Verification meant fetching OMNY's own publicly-served frontend bundle directly (the same JavaScript already sent to every visitor's browser, nothing authenticated or non-public touched), and separately cloning and reading the real, open-source library implicated in the failure, to determine precisely which side of the dependency the fault sits on.

Findings

Core account and payment transactions hang on a blocked analytics call (High)

Attempting to update a phone number, or load/add a fare card, on omny.info fails silently when the browser blocks outbound requests to the domain PostHog's bundled SDK calls out to. A standard privacy-extension "strict" tracking-protection mode is enough to trigger it. Console output on both failures:

posthog.0482b028.js:14 Enqueued failed request for retry in 26927
 POST https://pypestream.com net::ERR_BLOCKED_BY_CLIENT
posthog.0482b028.js:15 Bad HTTP status: 0
posthog.0482b028.js:14 Enqueued failed request for retry in 14257
 POST https://pypestream.com net::ERR_BLOCKED_BY_CLIENT

Same script, same hash, same blocked call, on both the phone-number flow and the card-loading flow: one shared root cause.

A correction: the blocked domain, pypestream.com, is misattributed as the analytics vendor. OMNY's real client bundle (fetched directly and confirmed across two separately-built deployments, client.197e4ff5.js and client.9209a1c2.js) shows Pypestream is a distinct integration, a customer-support chat widget (PYPESTREAM_CHATBOT_ELEMENT_ID, loaded from web.pypestream.com/launcher.js). Neither bundle contains the string "posthog" anywhere. The call to pypestream.com's domain comes from PostHog's own code, almost certainly a configured event-forwarding destination.

Ruling out the retry mechanism itself

The obvious read of that console output is "PostHog is stuck in an infinite retry loop." PostHog's client SDK, posthog-js, is open source, so this doesn't need to stay a guess. Cloned the real repository and read the actual file:

$ git clone --depth 50 https://github.com/PostHog/posthog-js.git
$ cd posthog-js && git grep -n "Enqueued failed request for retry"
packages/browser/src/retry-queue.ts:129:        let logMessage = `Enqueued failed request for retry in ${msToNextRetry}`

Reading retry-queue.ts directly: the queue is scheduled with setTimeout (asynchronous, never blocks other code running on the page) and bounded by a hard constant, STATUS_CODE_ZERO_MAX_RETRIES = 3, specifically for the blocked-request/CORS/ad-blocker case this bug hits. After three attempts it logs "Stopped retrying" and gives up on its own. That rules out a vendor-side defect as the cause. A well-behaved, bounded, non-blocking retry queue cannot, by itself, freeze a page.

Root cause: OMNY gates its own transaction on a telemetry call it doesn't control (High, CWE-754)

With PostHog's side confirmed clean, the fault is OMNY's own integration. Somewhere in the submission handlers for both flows, the real backend transaction, the profile update, the card load, is made to wait on that PostHog capture call succeeding. PostHog does exactly what a telemetry SDK should: tries, retries a bounded number of times, gives up gracefully. OMNY's code has no path forward once that happens, and the user is left staring at a hang with no error shown.

This is CWE-754, Improper Check for Unusual or Exceptional Conditions, verified against MITRE's definition. The code assumes a third-party telemetry call will eventually succeed and has no handling for the ordinary case where it doesn't.

Impact

This fails closed on the wrong dependency. A non-critical integration (analytics, routed through a support-chat vendor's domain for reasons still unexplained) is architected as a hard blocker for a security-relevant transaction (contact info tied to account recovery) and a financial one (loading fare payment). It penalizes users practicing ordinary browser hygiene: anyone running a privacy extension loses the ability to secure their account at precisely the moment that matters, and loses the ability to load a transit card.

The victim doesn't need a privacy extension of their own for this to be used against them. OMNY's own code is what gates the transaction on pypestream.com, not the attacker's, so anything that keeps that one domain from resolving or responding reproduces the same lockout. A phishing page or malicious ad/iframe could block that specific request client-side; an attacker on the victim's network path (a rogue hotspot, a compromised local DNS resolver) could drop it at the network layer. Neither was tested here — this is a plausible path on a confirmed root cause, flagged as such, not a tested exploit.

Mitigation

Decouple the PostHog capture call from the transaction path entirely. Fire the event, catch and log any rejection locally, and never let the real api.updateProfile() or card-loading backend call wait on, or be gated by, the telemetry call's outcome. PostHog's own SDK already fails gracefully after three attempts. OMNY's integration needs to do the same instead of treating that graceful failure as a reason to stop.

A second, unrelated failure on the same form (High)

Live-testing the phone-number edit flow directly, with the privacy-extension block confirmed active, surfaced a different failure. Every submission failed, regardless of what number was entered, three independent attempts, three different numbers, with a visible "Unexpected server error" instead of the silent hang above.

Console showed GoogleAnalytics.logException immediately before each failure, the same shape as the PostHog finding, analytics being blocked. The real network log told a different story: zero requests to any Google domain, zero to PostHog or Pypestream, fired during this flow. The actual GraphQL mutation POST returns HTTP 200, succeeding at the transport level. A translation lookup fires immediately after for the key miscText.error-backoffice-customer-contact-update-unknown-field, OMNY's own backend naming its own failure: the customer-contact-update mutation is rejected server-side with an unknown-field validation error. GoogleAnalytics.logException is OMNY's own error handler logging the caught exception, a symptom of the failure.

This explains why PIN creation and PIN change, tested the same session, both succeeded cleanly: a different mutation, one that doesn't touch the mismatched field. Two confirmed, independent, root-cause-distinct bugs on the same portal. This second one is the harder failure: it blocks every account holder from updating contact info right now, for any value, with or without a privacy extension.

Pulled the real mechanism directly from the client bundle: the Edit Contact form submits through the updateProfile mutation, which takes $phone: InputWSPhonePatch as one of its variables. The client builds that object as { number, country, op, oldPhone, oldType }, confirmed sent unmodified, no field renaming, by the serializer class backing it. GraphQL validates input-object fields strictly against the declared type, so a backend schema that's dropped one of op, oldPhone, or oldType produces exactly this "unknown field" rejection. The backend's current schema isn't visible from the client, so which specific field is rejected can't be confirmed further, but the mismatch is narrowed to this one five-field object.

Mitigation: fix the field mismatch between the frontend's mutation payload and the current backend schema, and surface the specific error OMNY's own backend already has a translation string for, instead of the generic fallback that gives account holders no actionable information.

No re-authentication on a sensitive account change (Medium, CWE-306)

Live-tested from an already-authenticated session: opened the profile edit form, changed only the email field this time, phone left untouched to isolate the variable, submitted. Result: immediate success, "You have updated your profile," no password re-entry or second factor requested anywhere in that flow.

This is a real, verified gap: CWE-306, Missing Authentication for Critical Function, with a directly matching recent precedent, CVE-2026-53981, an email-change endpoint in another product (Cap-go) that required no password or MFA before changing the address, classified under this exact CWE.

This was a fully authenticated user changing their own email, expected self-service on its own. The open question, not yet resolved: whether OMNY notifies the old email address when this happens. That's a plausible server-side action with no visible client request, so it couldn't be confirmed from the browser side. Until that's checked, the real-world exposure from a compromised session stays an open question rather than a settled one.

Verified directly with browser automation performing the identical sequence a human would, open the form, change the field, submit. The tooling defeated no additional barrier, because the live system presented none: the missing control means there's nothing to get past in the first place, for automation or anyone else holding the session.

Mitigation: require password re-entry or a fresh MFA challenge before committing a change to email or phone number. Confirmed the same gap on PIN change, live-tested earlier: no password or current PIN was requested before the new PIN took effect. Same mitigation applies.

A real misconfiguration, and one confirmed negative result (Low/Informational)

Live-testing the add-payment-method flow surfaced a genuine, independent misconfiguration in console: "Analytics key is missing for posthog! Please update config file for the same." Not an ad-blocker artifact, PostHog's own API key isn't correctly set in whatever config OMNY loads at runtime, a possible contributor to the pypestream.com routing oddity in Finding 1, though that connection isn't confirmed.

The same capture showed LaunchDarkly, OMNY's feature-flag vendor, hitting net::ERR_BLOCKED_BY_CLIENT repeatedly on its diagnostic and bulk-event endpoints, plus a dropped stream connection with an automatic retry, the same blocked-third-party pattern as Finding 1, a different vendor.

The useful negative result: the add-payment-method flow completed successfully despite LaunchDarkly and PostHog both being blocked throughout, the same missing-translation defect already seen on the address field fired three times along the way, but the transaction itself went through cleanly. Adding a payment method is not affected by the telemetry-coupling bug in Finding 1, a real boundary on how far that bug actually reaches.

Flagged, not tested: the flow revealed a numeric card identifier sitting directly in a client-side URL. If that identifier is sequential or otherwise guessable, that's the shape of a possible IDOR, accessing another customer's card by changing the number. Not tested, deliberately, confirming it would mean attempting to access a record that isn't the account holder's. Worth flagging for anyone with an authorized scope to check.

Threat model

Finding 1 is an availability failure that already happens to real users with no attacker involved. Finding 2 currently fails for every user regardless of who's trying, removing any targeting advantage by definition. Finding 3 is the genuine attacker-relevant gap: it doesn't create a path into an account on its own, it removes a barrier that limits the damage once a session is compromised by some other, unrelated means. Finding 4 is informational, a real misconfiguration and a confirmed boundary on Finding 1's reach, with one flagged, untested possibility that needs an authorized scope to check. No privilege was gained in this engagement, no other account was reached, lateral movement and account takeover weren't tested. Password reset and CSRF protection on OMNY's mutations also weren't tested, both stay open. No real financial transaction was executed against the fare-loading flow at any point.

The structural cause

Both bugs point at the same real structural cause. The backend error key for the second finding is literally named error-backoffice-customer-contact-update-unknown-field. "Backoffice" is doing real work in that name. Cubic Transportation Systems built OMNY under a 2017 MTA contract, and Cubic's own public materials confirm they specifically provide customer service and other back-office support, the literal system that error key is named after. In 2024, MTA pulled part of the OMNY rollout away from Cubic entirely, judging Cubic wouldn't finish until 2027, and brought in two more vendors on separate sole-source contracts, Masabi and Scheidt & Bachmann, to finish the remaining pieces.

OMNY runs on several separately-contracted codebases, not one team's. A public-facing web layer talks to a backoffice system, built and operated across multiple vendors, different companies, different timelines, coordinated by a public authority that isn't the engineering org for any of them. A contact-update mutation failing with "unknown field" against a system literally named backoffice is exactly the failure mode that structure produces: nothing forces the public-facing schema and the backoffice vendor's schema to stay in lockstep across a contract boundary. The telemetry-coupling bug fits the same shape from a different angle, a third-party integration wired in without anyone reasoning through what happens when it fails.

Same root pattern: the real risk in a system built from several separately-operated parts is usually the seam between the parts, not a sophisticated attack chain. A narrow integration trusted at one boundary, a telemetry vendor, a backoffice contractor, becomes load-bearing in ways neither side verified end to end. The failure lives at the boundary. Confirmed on both sides here, Cubic's actual contracted role and PostHog's actual open-source retry logic.

Disclosure status

No MTA-specific vulnerability disclosure channel or bug bounty exists, confirmed directly: no security.txt on either mta.info or omny.info, and NYC Cyber Command's public VDP is scoped to city-owned systems, MTA is a New York State authority, likely outside that scope. This report is being routed to MTA directly and to outlets with an established OMNY security reporting track record.