Edge proxy returns 302 → "/" for POST requests with credential-shaped JSON body

Hi Fly team,

I have an Astro app (Node adapter) deployed at app=“synaura” (org=personal, region=cdg, single machine 871476a0d93608, image v104). The app exposes login / signup / forgot-password endpoints. Since today (2026-05-13), every POST request whose JSON body contains common credential-shaped field-name pairs is being rewritten to a 302 → “/” by the Fly edge proxy BEFORE reaching the app.

This is breaking my entire authentication flow — users can’t log in, sign up, or reset passwords.

Reproduction (from outside any IDE / curl):

Case A — credential-shaped body, BLOCKED at edge:

$ curl -i -X POST https://synaura.app/api/auth/login
-H “Content-Type: application/json”
-d ‘{“email":"test@example.com”,“password”:“x”}’

HTTP/2 302
location: /
server: Fly/8829d9560 (2026-05-12)
via: 2 fly.io, 2 fly.io
fly-request-id: 01KRHBBG7Z043BSB3ZYJ9VRVCW-fra

-> response body empty, the app NEVER sees the request.

Case B — same endpoint, non-credential body, REACHES the app normally:

$ curl -i -X POST https://synaura.app/api/auth/login
-H “Content-Type: application/json”
-d ‘{“foo”:“bar”}’

HTTP/2 400
content-type: application/json
server: Fly/8829d9560 (2026-05-12)
fly-request-id: 01KRHBBGC3JM50TTNVSSDGKHAN-fra

{“ok”:false,“error”:“missing_fields”}

-> app responds normally, my own 400 is returned.

What I’ve tested (all blocked):

The edge proxy 302s ANY of the following field-name pairs when present together in a JSON POST body to my app:

  • email + password
  • mail + pwd
  • login + pass
  • id + token
  • userRef + secret
  • q1 + q2
  • x with a “##” separator value

The same proxy also 302s:

  • Authorization: Basic header (any value)
  • Any base64-encoded header value that decodes to “x:y” form
  • POST bodies with {v: [a, b]} and {args: [a, b]} shape
  • Custom header pairs X-Sn-U/X-Sn-P, X-Sn-Ident/X-Sn-Verify, X-Aux-1/X-Aux-2
    (the trigger seems to be applied after I deploy, suggesting a learning component)

What does pass through:

  • Plain GET requests
  • POST with non-credential-shaped JSON (e.g. {“foo”:“bar”})
  • POST with single-field JSON whose value is just an email-shape string

Questions:

  1. Is this a Fly edge feature I can opt out of (per-app config, env var, fly.toml)?
  2. Can you whitelist my app synaura to disable this credential-stuffing protection? My auth endpoints are rate-limited at the app layer + use argon2id; I don’t need the edge protection.
  3. If this is intentional and not configurable, what’s the recommended pattern for a self-hosted login form on Fly?

Reference values:

  • app: synaura (personal org)
  • machine: 871476a0d93608, region cdg, version 104
  • one blocked request id: 01KRHBBG7Z043BSB3ZYJ9VRVCW-fra
  • one passing request id: 01KRHBBGC3JM50TTNVSSDGKHAN-fra

Many thanks — happy to provide any more reproductions you’d like to see.

-– Plamen Mihalev

As a small side note… For “Hi Fly team” posts, it’s best to use the Questions / Help category (which I just added to this thread). That way, it’s inducted into Fly Support’s formal ticketing system.

https://community.fly.io/t/your-posts-are-now-getting-sent-directly-to-our-desks/27767

I’m not with Fly.io myself, but one useful tip is to use the flyio-debug: doit header in your curl requests. The answering flyio-debug field in the response carries information about what exactly happened, although the format is admittedly rather cryptic.

Hope this helps a little!

We do not do any filtering of this sort anywhere in our proxy stack. I tried a test request using the same curl command you posted, it ended up hitting your machine and your machine responded a 302. Please double-check if you have any kind of middleware in your app that does this.

A couple of quick tips @pmihalev:

  • Use code fences in your posts if you can, for code, config, console IO, logs, etc; it makes them much more readable
  • If you see an unusual behaviour around your app via HTTP, consider shelling into the machine and accessing it from localhost; this can be a handy debug method. Given Peter’s remark, I assume you’d get 302 in your current situation too.

Update for everyone landing here: this was 100% on my side, not a Fly issue.

Huge thanks @PeterCxy for the test that saved me hours of dead ends (“I tried your curl, it hit your machine and your machine returned the 302”) and @halfer for the flyio-debug: doit tip plus the “shell in and curl localhost” advice. Both pointers brought me to the actual cause.

Root cause

An Astro API route was throwing an uncaught SqliteError: no such column: source. Astro’s default behaviour on an uncaught error inside an endpoint is to render the error page, and with my i18n config that page does Astro.redirect('/') — so every “WAF-shaped” 302 I was seeing was just my own DB code crashing, swallowed silently.

The migration bug itself:

const SCHEMA = \`
  CREATE TABLE IF NOT EXISTS subscriptions ( … source TEXT … );
  CREATE INDEX  IF NOT EXISTS idx_subs_source ON subscriptions (source);
\`;

function db() {
  conn.exec(SCHEMA);    // throws here on a pre-existing subscriptions table
  runMigrations(conn);  // …never reached, so ALTER TABLE ADD COLUMN source
                        //  never runs either
}

On a fresh DB CREATE TABLE IF NOT EXISTS picks up the new column. On the existing prod DB it skipped the table, then CREATE INDEX referenced a column that didn’t exist yet. Moving the index out of SCHEMA and into runMigrations() (right after the ALTER TABLE ADD COLUMN source) fixed it.

Why I went down the WAF rabbit hole

The response shape — 302 → "/", empty body, via: 2 fly.io, 2 fly.io — looked identical to a credential-stuffing WAF response. Each body-shape change I tried ({email,password}{mail,pwd}{login,pass}{x:"…##…"}{args:[…]} → custom headers → Authorization Basic → …) seemed to toggle the behaviour, but only because some of my probes happened to hit the DB-import code path before the exception and others didn’t. Pure noise that I misread as a learning classifier. Sorry about that.

Lessons I’m taking home

  1. flyio-debug: doit should be the first thing I add to any “why is Fly doing X to my request” investigation. bn: null vs bn: <worker> would have told me in 10 seconds whether the request reached my backend.
  2. fly ssh console + curl localhost:8080 is the next thing. Both confirmed within a minute that the 302 came from inside my own machine, not from Fly’s stack.
  3. Wrap every API POST handler in an explicit try/catch that returns a JSON 500 — don’t trust the framework’s default error path on production endpoints, especially on i18n setups where the default error page itself redirects.
  4. SQLite ALTER TABLE additions must live in a migration step that runs before any DDL that references the new column.

Login / signup / forgot-password on synaura.app are all green now. Topic can be closed — thanks again to both of you, and sorry for the noise.

— Plamen