The attack you will actually get
Read this repository's sign-in action:
# app/controllers/auth/sessions_controller.rb
def create
result = Auth::AuthenticateUserService.call(
email: params[:email],
password: params[:password]
)
if result.success
# Read before reset_session clears it.
pending_link = session[:pending_partner_link]
reset_session
session[:user_id] = result.user.id
::Auth::CompletePartnerLinkService.call(user: result.user, pending: pending_link)
redirect_to post_sign_in_path(result.user), notice: t("auth.flash.welcome_back", username: result.user.username)
else
@error = result.error
flash.now[:alert] = @error
render :new, status: :unprocessable_content
end
end
That is careful code. It uses a service object, it calls reset_session on success (the session-fixation defense from the OWASP course) and it returns a generic error. Every decision in it is right.
And nothing in it counts. On its own, this action would accept ten thousand attempts a minute from one address. In this repository the counting lives elsewhere: Identity::User locks an account after repeated failures, and Rack::Attack throttles sign-ins by IP and by email, which lesson 7 reads. This course is about why those layers exist and how to choose them.
What arrives
Not a targeted attacker. What arrives is automated and indiscriminate:
| Traffic | What it wants | What it costs you |
|---|---|---|
| Credential stuffing | Reused passwords from other breaches | bcrypt work per attempt, and accounts |
| Scrapers | Your catalogue, at speed | Database load, and your content elsewhere |
| Vulnerability scanners | /wp-admin, /.env, /.git/config |
Noise, and a hit if anything is exposed |
| Password-reset floods | Nothing: or your email reputation | Your sender score, spent by someone else |
| Signup floods | Free accounts, for spam | Junk data and moderation |
Credential stuffing deserves the emphasis because of an asymmetry. The attacker is not guessing passwords; they have a list of real email-and-password pairs from someone else's breach and they are checking which ones work on your site. A strong password policy does not help: the password is strong, and it is also the one the user reused.
The only defenses are limiting attempts, a second factor, and checking against known-breached passwords. This course is the first.
What rate limiting is
A counter and a threshold: this identity, this many requests, in this window, then stop.
flowchart LR
R["Request"] --> D["Identify: IP? account? both?"]
D --> C["Count in the current window"]
C --> T{"over the limit?"}
T -->|"no"| A["pass through"]
T -->|"yes"| B["429, with Retry-After"]
Three decisions in that diagram, and each is a lesson: the identity is lesson 4, the counter's storage is lesson 5, the response is lesson 6.
What it is not
Being clear about this early prevents a category of misplaced confidence:
- Not a defense against a distributed attack. Ten thousand addresses making ten requests each defeats a per-IP limit completely. Rate limiting raises the cost of an attack from one machine; a botnet is a different problem.
- Not authorization. Limiting how often someone can call an endpoint says nothing about whether they should be able to.
- Not a substitute for a second factor. It slows credential stuffing down. Multi-factor authentication makes it useless.
- Not free. Every request now touches a counter, which is lesson 5's subject and is more expensive in this repository than you might expect.
The three layers
Rate limiting happens at three places, and they are complementary rather than alternative:
| Layer | Example | Sees | Cannot see |
|---|---|---|---|
| Edge | Cloudflare, a CDN, a cloud WAF | Volume, before it costs you anything | Who is signed in, what the action means |
| Proxy | nginx, kamal-proxy | Requests, per host | Application concepts |
| Application | Rails rate_limit, Rack::Attack |
The account, the action, the outcome | Traffic it never receives |
The edge is where volume is cheapest to stop, because the request never reaches your servers. The application is where meaning lives: this is a failed sign-in for this account is a sentence only your code can form.
A blanket edge limit of 100 requests a minute per IP does not stop 20 sign-in attempts a minute, which is plenty for credential stuffing. That gap is why this course is about the application layer, and lesson 6 returns to how the layers combine.
What this course covers
- The attack you will get (this lesson).
- Rails' built-in
rate_limit. - Rack::Attack fundamentals.
- Choosing the discriminator.
- The store, and what it costs.
- Responses, observability and tuning.
- Putting it together: protecting this application.
Key takeaways
- This repository's sign-in action is well written and counts nothing itself; the limits live in an account lockout and in Rack::Attack.
- Credential stuffing uses real passwords from other breaches, so a strong password policy does not defend against it.
- Rate limiting is an identity, a count, a window and a threshold: three of those are later lessons.
- It does not stop distributed attacks, replace authorization, or substitute for a second factor.
- The edge stops volume cheaply; the application is the only layer that knows what a request means.
Keep going with lesson 2
Rails' built-in rate_limit
Shows Rails' built-in rate_limit method, its options, its use on password resets, and what it cannot do.
A free account also gives you the quizzes, your progress and notes, and the discussion.