Bans held on every HTTP request but not the realtime socket

Every HTTP request re-checks whether a user is banned. The realtime socket checks a signature and nothing else.

A Twitch streamer-discovery platform: a React SPA, an Express and Mongo API, and a queue worker running as two dynos off one Heroku app, with a separate cross-origin admin panel reading the same API. The round was triggered by a third-party pentest that returned five findings, and grew into a wider review of the auth surfaces once the first one turned out to be a symptom rather than the disease.

Surface audited
27 route modules across 5 auth surfaces
Effort
1 session: static review, middleware probe, existing suite
Findings
1 critical, 3 high, 4 medium
Deadline
None: pentest remediation, undeployed

The situation

One repository holds two applications. The SPA builds into the API process, so the storefront and its backend share an origin. A second consumer, the admin panel, lives on its own domain and calls the same API cross-origin.

That single fact shapes most of what follows. Because the admin panel is cross-origin, the session cookies cannot use the browser's own CSRF protection, and that applies to every consumer rather than only the admin one.

Authentication is a token in an httpOnly cookie, with a refresh-rotation endpoint and a revocation blacklist. There are five distinct ways to present credentials: the cookie on HTTP requests, the refresh cookie, an OAuth session for social login, the realtime handshake, and a short-lived token minted for signup's live username checks.

Where the exposure actually is

The constraint is the cross-origin admin panel. It forces the cookie policy, it forced an allow-list, and it means no single auth change can be made without checking two consumers. The team understood that, and it was the frame for the pentest's findings.

The surprise is that the exposure was not in the HTTP layer at all. The HTTP middleware does the careful thing on every request: it verifies the cookie, checks the token against the blacklist, and re-reads the user's enforcement state so a ban takes effect on the next request. The realtime handshake, the other authenticated channel into the same system, does none of it. It verifies the signature, confirms a couple of claims are present, and connects.

The consequence is concrete. Moderation is the platform's core safety mechanism. Ban a user and their HTTP access stops on the next request, while their open socket stays authenticated for the full lifetime of the token. The same gap swallows revocation: logging out blacklists the token, and the socket never consults the blacklist.

Everything above came from code. The one finding that did not is the deploy path, and it is the one that would have caused an outage. The built client bundle was committed to the repository rather than built during deploy, and nothing in the server package rebuilt it. Shipping the round's server-side CSRF enforcement against the committed bundle, which contained no CSRF code at all, would have returned an error on every write in the application.

What we found

Ordered by severity. The criticals set the sequence below.

  • Critical

    The deploy ships a stale client bundle against a changed server

    The SPA bundle is committed rather than built on deploy, and the only build hook sits in a package the platform never runs. Deploying the round's CSRF work as committed fails every write: login, signup, follows, likes, uploads. Availability rather than vulnerability, but total.

  • High

    Realtime authentication enforces neither bans nor token revocation

    The handshake verifies the signature and stops. A banned or suspended user keeps a live authenticated socket until the token expires, and a logged-out token still connects. HTTP enforces both on every request, so the two channels disagree about who is allowed in.

  • High

    Unescaped user input reaches the database query layer on a public route

    Six filter fields interpolate raw query parameters into regular expressions, reachable unauthenticated through the public feed. A crafted pattern makes the database do exponential work on an open endpoint. A code comment records that the same class of bug was deliberately removed from this route in an earlier round; the utility it delegates to still had it.

  • High

    Profile update passes the raw request body into the model

    The request body is forwarded with no field allow-list. Only one of the two account types gets a sanitizer, so any writable schema field is reachable by a caller who names it.

  • Medium

    Cache TLS accepts any certificate

    The encrypted branch disables certificate verification, so the connection is encrypted but the server is never authenticated. That store holds the session blacklist and every rate-limit counter.

  • Medium

    Bot protection fails open when unconfigured

    A missing secret returns success and signup proceeds. The control silently becomes a no-op on any environment where the variable is unset rather than wrong.

  • Medium

    Password reset does not revoke existing sessions

    The change-password path revokes refresh tokens; the reset path updates the hash and returns. An attacker holding a stolen session keeps it through the victim's recovery, the one moment recovery exists to close.

Constraints and trade-offs

The admin panel could not be tightened symmetrically. Being cross-origin, it cannot participate in the double-submit token, and it is covered by the origin check alone until its own client learns to send the header. That was the right call, since the alternative was breaking a working internal tool, but it means the protection is not literally universal yet.

Account enumeration cannot be closed while the signup form asks the question. The live "username taken" hint is the feature; the oracle is that feature working. Scraping is now expensive rather than free. Removing it entirely requires changing the flow, which the team declined.

Nothing was exercised in a browser. Verification was static review, the existing 135-test suite, a client build, and an 18-case probe run against the real middleware. The admin panel was never run.

Sequencing

  1. Commit the built bundle with the server change (minutes)

    Nothing else can ship until this is true. One build, with the bundle in the same commit as the server code.

  2. Enforce bans and revocation in the handshake (half a day)

    Largest live exposure, and it reuses logic the HTTP middleware already has. Stop accepting the credential from the query string in the same change.

  3. Escape the filter builder (half a day)

    Public unauthenticated route. The escape helper already existed elsewhere in the codebase and needed applying consistently.

  4. Allow-list the fields profile update accepts (one day)

    Larger blast radius than the others but no external dependency. Extend the existing sanitizer to the second account path.

  5. Verify cache certificates, fail bot protection closed (one hour)

    Two config-level changes, both low risk, both currently silent when wrong.

  6. Revoke sessions on password reset (one hour)

    Small and mechanical. Reset should call what change-password already calls.

Outcome

Four of the five pentest findings were closed in the branch. The remaining work is sequenced above, and the deploy-path critical was caught before it could take the application down.

MetricBeforeAfter
Pentest findings closed0 of 54 of 5
State-changing endpoints behind a CSRF check0All but the payment webhook
HSTS max-age180 days365 days
Plaintext HTTP handlingServedRedirected or refused
All case studies

Build with
confidence.

AI builds the product. Vibecop makes sure it won’t break in production, fail under scale, or expose your users to risk. One audit. Fewer expensive surprises.