Skip to content

Commit 8a28783

Browse files
committed
Add cookbook recipe: going to production
1 parent d541db1 commit 8a28783

3 files changed

Lines changed: 103 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Going to production
2+
3+
The dev defaults are tuned for one process: state lives in memory, the rate limiter is in-process,
4+
and cookies are configured to work over plain HTTP. None of that survives more than one worker.
5+
Going to production is four changes, and the security model and the API stay identical, what moves
6+
is *where state lives* and the operational wiring around it.
7+
8+
This builds on any of the earlier recipes; the auth config itself doesn't change.
9+
10+
## 1. Move state to Redis
11+
12+
CRUDAuth keeps server-side state (sessions, CSRF tokens, lockout counters, single-use email and
13+
OAuth tokens) in a pluggable store. In memory, that state lives in the process, so under multiple
14+
workers or pods it isn't shared, which silently weakens lockout counters, sessions, and the
15+
atomicity of one-time tokens. Point both the session store and the rate limiter at Redis:
16+
17+
```python title="main.py"
18+
from crudauth import CRUDAuth, SessionTransport
19+
from crudauth.ratelimit import redis_rate_limiter
20+
21+
REDIS_URL = os.environ["REDIS_URL"]
22+
23+
auth = CRUDAuth(
24+
session=get_session, user_model=User, SECRET_KEY=os.environ["SECRET_KEY"],
25+
transports=[SessionTransport(backend="redis", redis_url=REDIS_URL)],
26+
rate_limiter=redis_rate_limiter(REDIS_URL),
27+
)
28+
```
29+
30+
`SessionTransport(backend="redis")` moves sessions, CSRF, and the one-time-token and OAuth-state
31+
stores to Redis; `redis_rate_limiter(...)` moves the lockout and throttle counters. CRUDAuth logs a
32+
startup warning whenever an in-memory backend is active, so a multi-worker deploy left on the
33+
default won't fail silently; if you've *deliberately* chosen in-memory on a single worker, pass
34+
`warn_on_memory_backend=False`.
35+
36+
## 2. Wire the lifespan
37+
38+
Redis backends open connections on startup, so call `initialize()` and `shutdown()` from your app's
39+
lifespan (it's required for Redis and a no-op for in-memory):
40+
41+
```python title="main.py"
42+
from contextlib import asynccontextmanager
43+
from fastapi import FastAPI
44+
45+
@asynccontextmanager
46+
async def lifespan(app):
47+
await auth.initialize()
48+
yield
49+
await auth.shutdown()
50+
51+
app = FastAPI(lifespan=lifespan)
52+
app.include_router(auth.router)
53+
```
54+
55+
## 3. Secrets and cookies
56+
57+
Two things the dev recipes glossed over:
58+
59+
- **The secret comes from the environment**, never a literal. `SECRET_KEY` signs every session and
60+
token; rotating it invalidates them all, so treat it like any other production secret.
61+
- **Cookies are secure by default.** `CookieConfig.secure` is `True` unless you turned it off, so
62+
serve over HTTPS and the session cookie is sent only over TLS. The dev recipes used
63+
`CookieConfig(secure=False)` to work on `http://localhost`; drop that in production. Don't ship
64+
`secure=False`.
65+
66+
## 4. Behind a load balancer
67+
68+
If you run behind a reverse proxy or load balancer, the socket peer is the proxy, not the user, so
69+
IP-based throttles would see every request as one client. Tell CRUDAuth how many trusted proxies sit
70+
in front so it reads the real client IP from `X-Forwarded-For`:
71+
72+
```python
73+
auth = CRUDAuth(..., trusted_proxy_hops=1) # one proxy/LB in front
74+
```
75+
76+
Set it to the actual number of hops you control; the default `0` ignores the header and uses the
77+
socket peer (correct when nothing is in front, safe when you're unsure).
78+
79+
## What production actually changes
80+
81+
Notice what didn't change: not the routes, not the gates, not a line of your authorization code. The
82+
API is identical to development. What changed is that state is now *shared and durable* (Redis), so
83+
lockout counters, sessions, and one-time-token atomicity actually hold across every worker and
84+
across restarts, and the operational edges are wired (lifespan connections, secrets from the
85+
environment, TLS-only cookies, the real client IP). CRUDAuth makes the one dangerous default loud
86+
rather than silent: it warns when a memory backend is active, so the multi-worker footgun announces
87+
itself instead of quietly degrading your security.
88+
89+
## Where to go next
90+
91+
- The backends in depth: [Storage & lifespan](../guides/infra/storage.md).
92+
- Tuning the limits: [Rate limiting & lockout](../guides/infra/rate-limiting.md).
93+
- Back to the recipes: the [cookbook index](index.md).

docs/cookbook/index.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ so you can copy one and have the account shape you want.
8686

8787
[Read →](existing-users-table.md)
8888

89+
- **[Going to production](going-to-production.md)**
90+
91+
---
92+
93+
Redis-backed shared state, lifespan wiring, secrets from the environment, secure cookies,
94+
and the real client IP behind a proxy.
95+
96+
[Read →](going-to-production.md)
97+
8998
</div>
9099

91100
**Prerequisites:** a FastAPI app and an async SQLAlchemy 2.0 session dependency. Each recipe

zensical.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ edit_uri = "edit/main/docs/"
129129
{ "Web and API in one backend" = "cookbook/web-and-api.md" },
130130
{ "Server-set fields at signup" = "cookbook/server-set-fields.md" },
131131
{ "Onboard an existing users table" = "cookbook/existing-users-table.md" },
132+
{ "Going to production" = "cookbook/going-to-production.md" },
132133
]
133134

134135
[[project.nav]]

0 commit comments

Comments
 (0)