C.W.K.
Stream
Lesson 04 of 06 · published

The Login Endpoint — Where Brute Force Begins

~15 min · login, form, rate-limit

Level 0Greenhorn
0 XP0/53 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete

The login endpoint is the only place an attacker can guess. Everything else gates on a session that the login endpoint issued. Get this one route right and the rest of the layer follows.

Why each line matters

LineWhat it defends against
Pre-check blacklistEven before bcrypt cost is paid; saves CPU on known-bad IPs
Pre-check attempt countBlocks the 6th attempt before it can succeed by guess
Constant-time delay on failRemoves timing as an oracle (PIN length, bcrypt cost)
Increment on fail, clear on successCounter resets so a few legitimate typos don't lock you out forever
HttpOnly, Secure, SameSite=strictStandard cookie hardening (Track 2)

Code

POST /login — the canonical solo-dev shape·python
import asyncio
from fastapi import FastAPI, Form, Request
from fastapi.responses import RedirectResponse, Response

app = FastAPI()

@app.post("/login")
async def login(request: Request, pin: str = Form(...)):
    ip = client_ip(request)

    # Pre-check: blacklist (defense in depth; middleware also blocks)
    if is_blacklisted(ip):
        return Response("Forbidden", status_code=403)

    # Pre-check: too many fails already?
    if attempt_count(ip) >= max_retry():
        blacklist(ip, reason="exceeded_retry")
        return Response("Locked out", status_code=429)

    # Verify PIN
    cfg = load_config()
    if not verify_pin(pin, cfg["pin_hash"]):
        increment_attempt(ip)
        # Constant-time response: don't leak 'PIN was wrong' vs 'user not found'
        await asyncio.sleep(0.5)        # also blunts timing attacks
        return Response("Invalid PIN", status_code=401)

    # Success: clear the counter, issue the session
    clear_attempts(ip)
    token    = create_session(ip)
    response = RedirectResponse("/", status_code=302)
    response.set_cookie(
        "session", token,
        max_age=cfg["session_seconds"],
        httponly=True, secure=True, samesite="strict", path="/"
    )
    return response
The login form — small UX touches that matter·html
<form method="POST" action="/login" autocomplete="off">
  <input
    name="pin"
    type="password"
    inputmode="numeric"
    pattern="[0-9]*"
    maxlength="4"
    autofocus
    autocomplete="off"
  />
  <button type="submit">Unlock</button>
</form>

External links

Exercise

Add the /login POST endpoint and a minimal HTML form. Test the success path (right PIN), failure path (wrong PIN, see the counter increment in security_attempts), and the lockout (5+ wrong attempts → blacklisted, 429). The exercise is wiring all three states; future tracks tune them.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.