Skip to content

Build1 publisher3 min readPublished

An edge rule of 3 requests per 10 seconds still admits 1,080 login attempts an hour

SyllogOS layers a Cloudflare Free rate limit, Turnstile and an in-app limiter in front of one login form. The author's own published thresholds show which of the three actually caps a patient attacker.

The Engineer · Build desk

Illustration accompanying An edge rule of 3 requests per 10 seconds still admits 1,080 login attempts an hour

What happened

  • SyllogOS, a multi-tenant platform for Greek cultural associations whose users are volunteers, runs its whole login defence on Cloudflare's Free plan and a single VPS.
  • The edge layer is one rate-limiting rule scoped to /api/auth/login, counting per IP, with a threshold of three requests in 10 seconds and a 10-second block.
  • The login route checks in a fixed order: the limiter, then the server-side Turnstile verification, then the credentials and the password hash.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint The Free plan's rate-limiting allowance is small enough that it covers one path, so every other endpoint on the origin depends on application-level controls alone.
  • cost Failing closed on Turnstile makes Cloudflare's reachability a precondition for a volunteer signing in, a price the author accepts for an admin panel and flags as wrong for a checkout.
  • decision Trusting CF-Connecting-IP obliges the operator to keep the origin unreachable directly; otherwise the header is attacker-supplied and per-visitor limiting counts whatever the attacker wants it to count.
  • capability Google sign-in gives the tenants two-step verification the platform never has to build, ship or support.

Count the guesses the two published thresholds allow. Stay just under the edge rule and you get 18 attempts a minute from one address, or 1,080 an hour [1]. The limiter inside the API allows 10 attempts and then locks for 10 minutes, a repeating 11-minute cycle worth about 54 attempts an hour [2]. The Cloudflare rule therefore permits roughly 20 times the guessing rate of the application rule [3]. The edge rule counts per IP [4], so an attacker holding 50 addresses gets 54,000 attempts an hour past that layer [4].

Alexandros is blunt about the layer he bought first. "It's a speed bump, not a wall," he wrote of the Cloudflare rule [5]. He also wrote: "A 10-second block barely slows a patient attacker, and anyone who can edit the zone can switch it off." [6] The Free plan's allowance covers one path, so it went on the single most attacked URL [3].

The 54-an-hour figure only holds if nginx can tell visitors apart. Behind Cloudflare every request reaches the origin from an edge address, and limiting on that address puts all visitors in one bucket: either one attacker locks out everyone, or nobody is ever locked out [7]. The real IP module restores the client address, and the published config lists Cloudflare's ranges in `set_real_ip_from` before setting `real_ip_header CF-Connecting-IP`; the excerpt shown is partial, and the full list at cloudflare.com/ips should be copied completely [8]. nginx forwards the restored address as `X-Forwarded-For $remote_addr`, and Express is set to `trust proxy` `loopback` so it trusts only the local hop [10]. The post warns that the header must never be trusted from any other source, because a directly reachable origin lets anyone send `CF-Connecting-IP` with any address they like [9]. Alexandros says he did not treat the work as done until his own home IP, not a Cloudflare address, showed up in the access log [11].

Turnstile runs in Managed mode and hands the browser a short-lived, single-use token [12]. A bot can skip the page and POST straight to the API, so the token means nothing until the server checks it with Cloudflare [13]. That check is one outbound POST to the siteverify endpoint carrying the secret, the token and the client IP, wrapped in `AbortSignal.timeout(5000)`, returning true only when `data.success === true` [14]. Every other path returns false. If Cloudflare cannot be reached, nobody logs in, which the author calls defensible for an admin panel and possibly wrong for a shop checkout [15].

Ordering follows cost: the limiter is cheap and protects what comes after it, Turnstile spends one outbound call per attempt and should not run for clients already blocked, and the password hash only runs for requests that passed both [19].

Google sign-in carries whatever the member's own account already enforces, two-step verification included, with nothing for the platform to build [20]. Registration through Google is refused. The callback requires `email_verified` to be true, looks for an active user with that lowercased address under the current tenant, and redirects to `/login?error=not_a_member` when there is no match [21].

One adoption detail can cost a day: Turnstile needs `https://challenges.cloudflare.com` in both `script-src` and `frame-src` of the Content-Security-Policy, or the widget silently fails to appear [17]. The site key is served from the API, so one widget covers the root domain and every tenant subdomain [16].

What to watch

  • Whether the in-API limiter counts per IP or per account. The post leaves that open, and a distributed attack makes the difference to the 54-an-hour figure.
  • Whether the VPS firewall restricts the origin to Cloudflare's ranges, since a directly reachable origin makes CF-Connecting-IP attacker-supplied.
  • Whether the set_real_ip_from list is refreshed when Cloudflare publishes new ranges; a stale list quietly returns nginx to edge-address bucketing.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories