2. API Keys & the Client Credentials Grant
Every grant type in Week 6 and Section 1 assumes a human is present to log in. A scheduled Celery task, a webhook receiver, or a server calling your API with no user in the loop needs a different pattern entirely — there's no browser to redirect and no password to prompt for.
The client credentials grant is OAuth2's answer: the calling service authenticates directly with its own client ID and secret, no user or redirect involved, and receives an access token scoped to what that service is allowed to do.
curl -X POST https://auth.acme.com/oauth2/token \
-u reporting-service:reporting-secret \
-d "grant_type=client_credentials&scope=reports:generate"
# {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600}
For simpler internal or partner integrations where full OAuth2 is more machinery than the situation needs, a plain API key — a long random string, checked against a hashed value stored server-side, tied to an owning account and a set of permissions — is a legitimate, lighter-weight alternative:
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key")
async def get_api_key_owner(
raw_key: str = Security(api_key_header),
db: AsyncSession = Depends(get_db),
) -> ApiKeyOwner:
key = await api_keys.find_by_hash(db, hash_key(raw_key))
if key is None or not key.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
return key.owner
The key's hash is what's stored and compared, exactly like Week 6's password hashing — a leaked database dump shouldn't hand out usable API keys any more than it should hand out usable passwords.
Client credentials gives you standard, short-lived, scoped tokens with a real revocation and rotation story built into the OAuth2 protocol — better for internal service-to-service calls where you control both ends. A plain API key is simpler to issue and integrate for external partners who don't want to implement a full OAuth2 client, at the cost of typically being longer-lived and needing your own revocation and rotation tooling built by hand.
3. Multi-Tenant Data Isolation
Once a single deployment serves multiple customers ("tenants") from shared infrastructure, the single most important property the application has to guarantee is that Tenant A's request can never return Tenant B's data — not "usually doesn't," but structurally cannot. Three common strategies, in increasing order of isolation and operational cost:
- Shared schema, discriminator column — every table gets a
tenant_idcolumn, and every query filters by it. Cheapest to run and simplest to add a tenant to, but the isolation is only as strong as every single query remembering to filter correctly. - Schema-per-tenant — one PostgreSQL schema per tenant, same database instance. Stronger isolation (a missing filter can't cross schemas as easily), but migrations and connection routing get meaningfully more complex as tenant count grows.
- Database-per-tenant — full physical isolation. Strongest guarantee, but the most operationally expensive: connection pooling, migrations, and backups now all multiply by tenant count.
For the common shared-schema approach, PostgreSQL's row-level security
(RLS) enforces the tenant boundary directly in the database, rather than
trusting every hand-written query to remember the WHERE tenant_id = ?
clause:
ALTER TABLE task ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task
USING (tenant_id = current_setting('app.current_tenant_id')::int);
@app.middleware("http")
async def set_tenant_context(request: Request, call_next):
tenant_id = get_tenant_from_auth(request) # from the authenticated principal
async with db_session() as session:
await session.execute(
text("SET app.current_tenant_id = :tid"), {"tid": tenant_id}
)
request.state.db = session
response = await call_next(request)
return response
With RLS enabled, every query issued through that connection for the rest of the request is automatically filtered by the current tenant setting at the database level — a developer writing a new repository method later can't accidentally forget the filter, because it isn't something they write per query at all; even a raw SQL query bypassing the ORM entirely is still constrained by the database itself.
Unlike most bugs, a query that returns another tenant's data is a genuine security incident and breach of contract, not just a defect — treat any code path that queries tenant-scoped data without going through the RLS-protected connection as a blocking issue in code review, and add an integration test that specifically asserts Tenant A's API calls, using Tenant A's credentials, can never return a row belonging to Tenant B.
4. Hands-on Exercise
Stand up your own auth server, add a machine client, and enforce tenant isolation
Apply all three practices to the task service from earlier weeks.
Requirements:
- Stand up an authorization-code-grant OAuth2 provider with
authlib, registering one browser-based client, and confirm a full login flow issues a real, working access token. - Register a second, machine-only client using the client credentials grant, and confirm it can fetch a token and call a protected endpoint with no user or browser involved.
- Add a
tenant_idcolumn to your task table, seed data for at least two tenants, and implement PostgreSQL row-level security to enforce isolation. - Write an integration test that authenticates as Tenant A and asserts every list/search endpoint returns zero rows belonging to Tenant B, even when Tenant B's IDs are guessed directly in a path parameter.
Test the isolation boundary by directly requesting a resource ID you know belongs to the other tenant — GET /api/tasks/{known-tenant-b-id} while authenticated as Tenant A should return 404, not the data, and definitely not a 403 that confirms the resource exists.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
What's the difference between the OAuth2 password flow from Week 6 and the authorization code flow this week's provider implements?
What's the difference between the OAuth2 password flow from Week 6 and the authorization code flow this week's provider implements?
The password flow has the client collect the user's credentials directly and send them to your API to exchange for a token — simple, but it requires the client to be fully trusted with raw credentials. The authorization code flow redirects the user to a login page your server controls, then exchanges a short-lived code for a token, without the client application ever seeing the user's actual password — the standard flow for third-party or less-trusted clients.
Q2
Why does the client credentials grant not involve a redirect or a login form, unlike the authorization code grant?
Why does the client credentials grant not involve a redirect or a login form, unlike the authorization code grant?
The client credentials grant exists specifically for machine-to-machine calls where no human is present to authenticate — the calling service proves its own identity directly with a client ID and secret, in one request, rather than needing a browser to redirect a user through a login screen and back.
Q3
Why is enforcing tenant isolation with PostgreSQL row-level security more robust than trusting every repository function to add its own WHERE tenant_id = ? clause?
Why is enforcing tenant isolation with PostgreSQL row-level security more robust than trusting every repository function to add its own WHERE tenant_id = ? clause?
RLS enforces the tenant filter inside the database itself, based on a session-level setting, so every query issued over that connection is constrained automatically — including raw SQL that bypasses the ORM entirely. Relying on every individual query to manually include the tenant condition means a single missed filter, anywhere in the codebase, is a cross-tenant data leak; RLS makes that class of bug structurally impossible rather than a matter of developer discipline.