A from-scratch Flask authentication service: JWT access + refresh tokens with rotation and reuse detection, real session/device revocation, password reset, email verification, and TOTP 2FA with backup codes.
Nobody has started this yet — be first.
Signup rejects a valid 8-character password
Support gets a slow trickle of "your signup form is broken" tickets from users who read "at least 8 characters," typed exactly 8, and got rejected anyway with a message that contradicts what just happened. It is not a security hole, but it is the kind of paper-cut that erodes trust in the product on the very first screen a new user sees, and every occurrence is a support contact that did not need to happen.
About this project
A from-scratch Flask auth service covering signup/login, JWT access + refresh tokens with rotation and reuse detection, session ("logged-in devices") management with real revocation, password reset, email verification, and TOTP 2FA with backup codes. This is deliberately not "add JWT to an app" -- it is built around the handful of details that separate a real auth system from a tutorial one: refresh tokens are one-time use, and replaying an already-used one kills the entire session family, not just that one token; logging out (or resetting a password) actually invalidates already-issued access tokens immediately via a Redis denylist keyed by JWT jti, not just future ones, even though access tokens are stateless JWTs chosen specifically for fast verification; a TOTP code cannot be submitted twice, even though it is technically still "valid" for the rest of its 30-second window, by tracking the last-consumed step per user; and login, password-reset-request, and email-verification-resend are all rate limited on two independent axes (client IP and target account), so neither a distributed attack spread across many IPs at one account nor a single IP hammering many accounts slips through. Two hashing algorithms are used on purpose -- argon2id for human-chosen passwords, fast SHA-256 for high-entropy server-generated tokens that get rehashed on every request that presents them.
2FA confirmation issues 9 backup codes instead of 10
`BACKUP_CODES_COUNT` (10) is the number of recovery codes the product promises a user when they enable 2FA -- likely displayed in the UI as "codes 1-10." Only 9 are actually created. If a user methodically uses codes in order and treats "#10" as still available, they will hit a dead end at the worst possible time: locked out of their account, out of a working recovery code, forced into a support-assisted account-recovery flow.
Fifteen practice tickets are injected into an otherwise-working codebase: ten are real, minimal, single-mistake bugs (an inverted <=/< boundary on a password-length check, an off-by-one loop bound that silently generates one fewer backup code than promised, an inverted TLS boolean that sends password-reset emails over plaintext SMTP, a min/max mix-up that reports a Retry-After of zero, a missing cleanup statement that leaves old 2FA backup codes valid forever, a classic IDOR with no ownership check on session revocation, a genuine asyncio-free-but-still-real SQLAlchemy race with no WHERE-guarded update in refresh-token rotation, and a reordered pair of checks that silently disables the whole project's headline reuse-detection security property), and five are half-built enhancements -- a fully-wired stub endpoint that always 501s, an already-scaffolded extension point (account lockout, audit logging) with every function a no-op, and a config value (TOTP_DIGITS, REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS) read correctly on one side of a feature but ignored on the other -- the smallest thing that compiles, never a disguised version of the real fix. Ordered easy to hard, each ticket has its own currently-failing pytest test that goes green when (and only when) that ticket is fixed; no solutions are given anywhere in the ticket docs. Nine of the ten injected bugs are real enough that they also break tests in the project's own main suite (as of writing: tickets 02, 03, 07, 11, and 15) -- expected, not a second problem to chase separately, since fixing the ticket fixes both. The hardest ticket, TICKET-15, is a genuine, security-critical logic-ordering bug in refresh-token reuse detection: because a rotated token always has both revoked_at and replaced_by set, checking revoked_at first makes the replaced_by-driven family-kill branch permanently dead code, so replaying a stolen, already-rotated refresh token is silently treated as "just revoked" instead of "compromise detected" -- every surface-level check (the replay itself gets rejected) still passes, and only checking whether the successor token also died reveals the regression.
<=<minmaxRetry-AfterasyncioWHERETOTP_DIGITSREMEMBER_ME_REFRESH_TOKEN_TTL_DAYSrevoked_atreplaced_byrevoked_atreplaced_bymake docker-prod-upYou do not need Docker running at all to work the tickets -- the test suite uses an in-memory SQLite database (via StaticPool, an isolated DB per test function) and fakeredis (TestingConfig.USE_FAKE_REDIS), so everything runs against a plain virtualenv:
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest practice_tickets/tests/test_ticket_01.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
All 15 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. make test (pytest -q) runs the project's own main suite -- as of writing it shows 9 pre-existing failures caused by ticket bugs 02, 03, 07, 11, and 15; fixing those tickets fixes those failures too, not a separate task. Do not look at git diff / version control history to find the bugs if this repo is ever put under source control -- the point is to find them the way you would find a real bug: from a failing test and a description of broken behavior, not a diff.
How to run it locally, step by step.