Docs / Security▾

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.

Refresh token rotationAppParkezy APIAttackerrefresh with R1new access + R2 (R1 revoked)refresh with stolen R1R1 was already rotated: reuse detectedrevoke every token in the family401 session expiredrefresh with R2401: sign in again

Swipe sideways to see the whole figure.

If an attacker steals a refresh token and either party uses it after it was rotated, the whole family is revoked and both must sign in again: the real user notices, the attacker loses access.

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.

app/infra/rate_limit.py (simplified)python
# estimated = current + previous * (1 - elapsed_fraction_of_current_window)
pipe = redis.pipeline(transaction=True) # MULTI/EXEC: atomic across workers
pipe.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 limitedKeyed byWhy
Sign-in, sign-up, password resetIP (login also per email)The per-email limit holds even when guesses rotate through many IPs
Reset and verification emailsthe email addressStops inbox bombing and protects the email quota
Gate verification (QR / PIN)the hostA 6-digit PIN is only safe if nobody can try thousands
Booking requeststhe userFair share per account
Session refreshIP, with a looser limitApps 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 hashing
Failure history, spread of failures, service-wide attack level, known device.

Low risk

Normal sign-in: the password is verified as usual.

Medium risk

A challenge (CAPTCHA) is required first, or a short slow-down.

High risk

Temporary 429 with Retry-After. The password is never hashed.

Record the outcome

Failures feed the counters; a success clears the account's failures and remembers the device.
All signals are read in one atomic Redis round trip. No single signal can block a request on its own, and nothing is permanent: every counter expires.

Why it is cheap to defend and expensive to attack

Measured on our development machine. Rejected attempts cost about 1 ms of Redis work instead of ~68 ms of argon2. In a simulated flood of 1,000 failed logins from rotating addresses, 700 were stopped before hashing.
  • 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#

ThreatDefence
Huge or deeply nested bodiesRequests 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 idsStrict Pydantic types; all clean 4xx in our hostile-input probe
Tampered or alg=none JWTsFixed algorithm, issuer, audience and purpose; expiry checked against the server clock
Malicious uploadsPresigned URLs with fixed type and size; the server checks the stored object before using it
Clickjacking, sniffing, referrer leaksnosniff, frame DENY, no-referrer, HSTS and no-store on every response
Leaked secretsSecrets only in platform dashboards and EAS; startup refuses short JWT secrets or CORS=* in production