Forgia goes live next week with a beta cohort. Before that, one of the open security items had to close: the login endpoint had no ceiling. Anyone could POST /users/log-in in a loop.
The instinct is to reach for the standard recipe — throttle login attempts per IP, five a minute, done. That recipe is written for password logins, and Forgia doesn’t have one. A Magic Link is the only way a Student signs in. There is no credential to guess, so there is nothing to brute-force.
Which means the threat is not guessing. It’s spending. Both anonymous endpoints — the Magic Link request and registration — spend two things on a stranger’s behalf, and neither is yours to give away:
- Somebody else’s inbox. Type a victim’s address in a loop and we mail-bomb them, with our own domain, from our own reputation.
- A finite mail quota. Every request is an outbound e-mail. Exhaust the plan and nobody can log in — because the Magic Link is the only door. The denial-of-service isn’t against the server; it’s against the front door key.
That reframing changes the design, because it tells you what to key on.
Two keys, because they defend different things
A per-IP ceiling defends the quota: it stops one machine from emptying our sending plan. It does not defend a victim — rotate IPs and the same inbox still drowns.
A per-e-mail ceiling defends the victim: three requests per fifteen minutes for [email protected], no matter where on Earth they come from. It does not defend the quota — vary the address and you’re spending again.
Neither key subsumes the other. So we count both.
And a subtlety on the IP side: five per minute is a burst ceiling, not a volume ceiling. A patient attacker who sends exactly five a minute, forever, is never denied and mails 7 200 messages a day. So the IP gets two windows — 5/minute and 20/hour. The first stops the flood; the second stops the drip.
flowchart TD
R["POST /users/log-in
POST /users/register"] --> A{"auth_ip_burst
5 / minute"}
A -->|over| D["429 — Too many attempts"]
A -->|under| B{"auth_ip_hourly
20 / hour"}
B -->|over| D
B -->|under| C{"auth_email
3 / 15 min"}
C -->|over| D
C -->|under| E["Mint the Magic Link
and send it"]
X["counter raises"] -.->|fail open, log it| E
The checks short-circuit, and the order is deliberate: a request already refused by IP never reaches the e-mail counter. Otherwise an attacker could burn your address’s budget with requests that were never served — and you’d arrive at your own ceiling having received nothing.
One more thing the diagram says out loud: both endpoints share these counters. Not login_ip: and register_ip:, just auth_ip:. Give each endpoint its own bucket and an attacker alternates the two URLs and walks away with double the budget — while the protected resource, the inbox and the quota, was the same one either way.
The trap: conn.remote_ip is a lie
Here is the part worth the whole post.
Forgia runs behind Caddy, which terminates TLS and reverse-proxies to Phoenix over loopback. So conn.remote_ip — the thing every rate-limiting tutorial keys on — is 127.0.0.1. For every request. From everyone. Forever.
Key a per-IP limiter on that and you have not built a per-IP limiter. You’ve built a global one. Twenty requests an hour, shared by the entire internet. The first twenty students to try to log in consume it, get thrown a 429, and the attacker — who is one of those twenty and doesn’t care — carries on. It is worse than having no limiter at all, because it fails closed, on the wrong people, silently.
The fix is a plug, before the router:
plug RemoteIp
RemoteIp’s defaults already treat loopback as a proxy and recover the client from X-Forwarded-For. Which is trustworthy here because Caddy appends the real peer to whatever the client sent — so the last entry is the one the client cannot forge.
I would like to report that I knew all this and simply wrote it down. What actually happened is that my own test suite fell into the trap. I generated fake client IPs like this:
defp unique_ip, do: "203.0.113.#{System.unique_integer([:positive])}"
System.unique_integer/1 returns numbers like 576460752303423488. That is not an octet. 203.0.113.576460752303423488 is not an address. RemoteIp couldn’t parse the header, shrugged, and left remote_ip as the proxy’s 127.0.0.1 — dropping every test into one shared bucket, exactly like production would have. The tests went red in a way that looked like a bug in the limiter and was in fact a perfect reproduction of the bug the limiter exists to avoid.
It fails silently. That’s the whole lesson. Nothing raises, nothing logs, the header is simply ignored — and the only symptom is that the thing throttles the wrong people. Assert on it:
test "buckets by the real client IP, not by the proxy's loopback address" do
noisy = unique_ip()
for _ <- 1..3, do: request_magic_link(noisy, unique_email())
assert request_magic_link(noisy, unique_email()).status == 429
quiet = unique_ip()
assert request_magic_link(quiet, unique_email()).status == 302
end
If X-Forwarded-For were being ignored, the second client would inherit the first’s exhausted budget and this test would go red. It is the only test in the file that would notice.
What the Student sees

An explicit message, not silence. The alternative — quietly declining to send while still saying “if your email is in our system, you’ll receive instructions shortly” — gives an attacker nothing, but it also leaves a legitimate Student staring at an inbox waiting for a mail that is never coming.
And it leaks nothing, because the ceiling applies to every address alike. A throttled response for a registered e-mail and a throttled response for one we’ve never seen are byte-for-byte identical — which is a test, not a hope:
assert body_without_csrf(known) == body_without_csrf(stranger)
It fails open
If the counter itself blows up — ETS gone, a bad config value, anything — the request is allowed, and the error is logged.
This looks like the wrong call for thirty seconds, and then it doesn’t. A rate limiter that denies everything when it breaks is the login outage it was built to prevent, except now we’re the ones causing it. The Throttle protects against an attacker spending our quota. It must never become a way to lock every Student out of the platform because an ETS table went missing.
Two footnotes for anyone copying this
The canonical Elixir article is one major version stale. Paraxial’s auth rate-limiting post — which is right about everything that matters, including the proxy trap — shows Hammer.check_rate/3. That’s Hammer 6. Hammer 7 wants a module of your own and a different call:
defmodule Forgia.RateLimit do
use Hammer, backend: :ets
end
# {:allow, count} | {:deny, retry_after_ms}
Forgia.RateLimit.hit("auth_ip_burst:#{ip}", 60_000, 5)
The ceilings are env-tunable, on purpose. Not for elegance — for a specific 9pm phone call. Students behind one office NAT or one VPN exit node share an IP, and the day a cohort starts seeing Too many attempts, the fix has to be a restart, not a deploy:
THROTTLE_IP_BURST_LIMIT=5
THROTTLE_IP_HOURLY_LIMIT=20
THROTTLE_EMAIL_LIMIT=3
The per-e-mail ceiling, notably, is immune to that whole class of complaint. It protects a victim, not an origin — so a shared exit node doesn’t affect it at all.
The one that came free
The Throttle sits in a plug ahead of the action, so it covers every clause of create/2 — including the e-mail + password branch.
Forgia has no password field on any screen. But the endpoint still accepts user[password], and a user can set a password in settings. Which means password brute-force was reachable today, through a door with no sign on it.
It isn’t anymore. That one wasn’t in the story. It was in the router.