Quality
Security
Layered defences: slow password hashing, short-lived tokens with stolen-token detection, rate limits that work across many servers, adaptive protection against credential stuffing, and strict input handling everywhere.
Passwords#
- Hashed with argon2id (memory-hard: about 70 ms of CPU and 64 MiB per hash), and re-hashed on login if the parameters are ever raised.
- Hashing runs in a thread pool with a small concurrency cap, so a burst of sign-ins queues up instead of freezing every other request. The database connection is returned before hashing starts.
- Unknown emails are checked against a dummy hash, so “no such account” takes as long as “wrong password”: response time does not reveal who has an account.
- Errors are identical for both cases:
401 Incorrect email or password.
Sessions: short access tokens, rotating refresh tokens#
The app holds a 15-minute JWT access token in memory and a 30-day refresh token in the phone's secure keystore. Refresh tokens are opaque random strings, stored only as hashes, and replaced on every use. They are grouped into families, so reuse of an old one is a strong sign of theft.
Swipe sideways to see the whole figure.
Each access token also carries the user's token version. Changing or resetting the password, deleting the account or an admin suspension bumps it, which signs out every device at once.
Rate limiting across many servers#
Limits are kept in Redis, so they hold no matter how many API instances run. We use a sliding-window counter: a fixed window lets a client burst twice the limit across a window edge, while this estimate smooths the edge using just two counters per client.
# estimated = current + previous * (1 - elapsed_fraction_of_current_window)pipe = redis.pipeline(transaction=True) # MULTI/EXEC: atomic across workerspipe.incr(current_key)pipe.expire(current_key, window_seconds * 2)pipe.get(previous_key)current, _, previous = await pipe.execute()estimated = int(current) + int(previous or 0) * (1 - elapsed)if estimated > limit: raise RateLimited(details={"retry_after_seconds": reset}) # 429 + Retry-After| What is limited | Keyed by | Why |
|---|---|---|
| Sign-in, sign-up, password reset | IP (login also per email) | The per-email limit holds even when guesses rotate through many IPs |
| Reset and verification emails | the email address | Stops inbox bombing and protects the email quota |
| Gate verification (QR / PIN) | the host | A 6-digit PIN is only safe if nobody can try thousands |
| Booking requests | the user | Fair share per account |
| Session refresh | IP, with a looser limit | Apps refresh in the background; many users can share one IP |
Adaptive login protection#
Plain rate limits treat a typo and a botnet the same. Before any password is hashed, the API scores each login attempt from several independent signals: recent failures for the account, the network and the device, how widely failures are spread across accounts and addresses, attack activity across the whole service, and whether this device has signed in to this account before.
Cheap risk check
≈1 ms, before hashingLow risk
Medium risk
High risk
Record the outcome
Why it is cheap to defend and expensive to attack
- Many real users behind one campus or carrier address are not penalised: only failures count, never successful sign-ins.
- An attacker hammering someone's email can trigger challenges for that account, but the owner on their usual phone is never locked out.
- Challenge tokens are verified with the provider on the server, accepted once, and checked for age, hostname and action. A “success” flag from the client is ignored.
- If Redis is unavailable, a small per-server fallback still caps attempts, and real users can still sign in.
Input and transport hardening#
| Threat | Defence |
|---|---|
| Huge or deeply nested bodies | Requests over 1 MiB rejected with 413 before they are read |
| NUL bytes (crash Postgres text) | Rejected in every string, query and path parameter (400) before any database work |
| NaN, infinity, year 0001 dates, garbage ids | Strict Pydantic types; all clean 4xx in our hostile-input probe |
| Tampered or alg=none JWTs | Fixed algorithm, issuer, audience and purpose; expiry checked against the server clock |
| Malicious uploads | Presigned URLs with fixed type and size; the server checks the stored object before using it |
| Clickjacking, sniffing, referrer leaks | nosniff, frame DENY, no-referrer, HSTS and no-store on every response |
| Leaked secrets | Secrets only in platform dashboards and EAS; startup refuses short JWT secrets or CORS=* in production |