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.
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:
Is this a Fly edge feature I can opt out of (per-app config, env var, fly.toml)?
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.
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.
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.
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.
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.
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
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.
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.
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.
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.