---
title: "Jeder Reset-Link lieferte 400"
description: "Der Endpoint war in Ordnung. Das Token war in Ordnung. Die E-Mail war in Ordnung. Der Fehler lag in einem Pfad einer Captcha-Konfiguration, und das schädliche Wort hieß „includes“."
url: https://riteshkc.com.np/de/blog/every-reset-link-returned-400
source: https://riteshkc.com.np/de/blog/every-reset-link-returned-400.md
updated: 2026-08-05
site: "Ritesh KC"
---
# Jeder Reset-Link lieferte 400

Der Endpoint war in Ordnung. Das Token war in Ordnung. Die E-Mail war in Ordnung. Der Fehler lag in einem Pfad einer Captcha-Konfiguration, und das schädliche Wort hieß „includes“.

- Published: 2026-08-05
- Tags: Security, Auth, Debugging
- Author: Ritesh KC (https://riteshkc.com.np)

Forgot-password worked. You typed your email, the form said check your inbox, and the email arrived: correct branding, correct link, correct token.

Click the link and you got a 400.

Not a "token expired" page. Not a redirect to login. A raw 400 from the API, in a browser tab, with a JSON body, which is roughly the worst thing a user can be shown in the middle of trying to get back into their account.

## What I checked first, and what was fine

The token. Freshly issued, unexpired, present in the URL, present in the database.

The handler. It ran correctly against curl. Against the exact same token from the exact same email, a manual request succeeded and the password changed.

That last part is the interesting one, and I sat on it for longer than I should have. The same token, the same route, the same server: succeeding from my terminal and failing from Gmail. Which meant the request was being rejected before it ever reached the handler, based on something about *how* it arrived rather than *what* it carried.

## One header, one list

The site puts Cloudflare Turnstile behind a single header (`x-captcha-response`) on both the auth endpoints and the public marketing forms, so there is one thing to send and one thing to verify no matter which surface you're on.

On the auth side that's better-auth's captcha plugin, and it's configured with a list of paths:

```ts
captcha({
  provider: "cloudflare-turnstile",
  secretKey: process.env.TURNSTILE_SECRET_KEY,
  endpoints: [
    "/sign-in/email",
    "/sign-up/email",
    "/forget-password",
    "/reset-password",
  ],
});
```

Read that list. It names four endpoints. It is four endpoints in the sense that a human means four endpoints.

The plugin does not match by equality. It matches by containment: is the request path *in* one of these. So `/reset-password` on that list does not protect one route. It protects everything with `/reset-password` in front of it, and the route that inherited the protection was `/reset-password/:token`: the GET a user performs by clicking a link in their email client.

An email client cannot attach a custom header to a link. There is no request the browser could possibly send that carries `x-captcha-response` here, because nothing on my page issued it: the user clicked a plain anchor tag from Gmail. The captcha layer looked for the header, found nothing, and returned 400 before any of my code ran.

The POST that requests a reset was genuinely protected and genuinely working. That's why the flow looked half-healthy instead of broken: the half a bot would abuse worked perfectly, and the half a locked-out human needs did not.

## Why it never showed up locally

Unsetting the Turnstile keys disables enforcement on both the client and the server, deliberately, so a new developer can run the whole stack without a Cloudflare account. Locally I had no keys. Locally there was no captcha layer at all, and the reset link worked every single time.

The bug existed only where the keys did.

## The fix is a deletion

Delete `/reset-password` from the list. Nothing replaces it, because a captcha was always the wrong control here: it exists to make a submission expensive to repeat at volume, and the thing worth stopping on a token-consume route is guessing, not volume. Guessing gets a rate limit:

```ts
"/forget-password": { window: 300, max: 3 },
"/reset-password": { window: 300, max: 5 },
```

Five attempts per five minutes, keyed on IP. Against the token space that's not a defence so much as a statement that brute force isn't going to be pleasant, and unlike the captcha it costs a real user holding a real link exactly nothing.

Then the line that matters more than either of them:

```ts
// /reset-password is deliberately absent from the captcha endpoints: the
// plugin matches by substring, so listing it also gates the GET email-link
// callback /reset-password/:token, which cannot carry x-captcha-response.
// Every reset link 400'd. Rate limiting covers this path instead.
```

Because the shipped state of that config is now a list of protected auth routes with one obvious hole in it, and holes get filled. Someone tightening security a year from now adds the missing line in good faith, in a one-line diff nobody blocks, and breaks password reset for everyone who isn't already logged in, which is, by definition, everyone who needs it.

The comment is the whole fix. The deletion just stops the bleeding.

## Two things I took from this

**Find out how your path lists match.** Equality, prefix, or containment: they read identically in the config and behave nothing alike. That list looked like an enumeration of four routes. It was four match rules, and a `:param` route sitting underneath one of them inherited a protection nobody chose for it.

**A protection that requires a header only covers requests your own JavaScript makes.** Email links, OAuth callbacks, unsubscribe clicks, anything a user reaches by clicking outside your app: none of it goes through your fetch wrapper, so none of it can be defended by something the wrapper attaches. That's not a gap to plug harder. It's a category of request that needs a control it can actually carry.

## Related work

- [Gradsy](https://riteshkc.com.np/de/work/gradsy): Der gesamte Betrieb einer Auslandsstudien-Beratung über zwei Repos. Studierende bewerben sich, Berater bewegen Bewerbungen durch Zustände, Admins machen den Rest. Viel davon war Nebenläufigkeit.
