The previous lesson gave StayHub its own login: a password, a hash, a JWT it signs itself. This one adds the other kind — the button that says Sign in with Google — and it is a genuinely different problem, because the middle of it happens on a domain you do not control and the security lives in two parameters that are easy to leave out.
It also finishes something lesson nine started. That lesson pointed at
OAuth2PasswordBearer, explained that the Authorize button on
/docs needs a token endpoint in a specific shape, and left it unbuilt. StayHub has one
now.
OAuth2 is not a login system
This is the confusion behind most of the mistakes below, so it is worth being blunt. OAuth2 is a delegated authorization protocol. It answers "this application may act on my behalf, for these scopes, until I revoke it". The word authentication does not appear in its job description, and an OAuth2 access token tells you nothing reliable about who anybody is — only that some token bearer was granted some access.
What people actually want from a sign-in button is OpenID Connect, a thin layer
on top of OAuth2 that adds an identity document: an id_token, and a
userinfo endpoint that returns standard claims — sub,
email, email_verified. Google is an OIDC provider. GitHub is not, which
is why it needs special handling later.
The practical consequence: the access token a provider gives you is not proof of identity, and it is not yours to hand to your own frontend. It is a key to that provider's API. Your server uses it once, to ask who the person is, and then mints its own token.
Which flow you need
| Flow | Use it for | Verdict |
|---|---|---|
| Authorization code + PKCE | a user signing in via a third party | the one you want |
| Client credentials | service-to-service, no user involved | correct, different job |
| Password grant | your own first-party login | removed in OAuth 2.1 |
| Implicit | — | dead; returned tokens in a URL |
The password grant is the one FastAPI's own tutorial teaches, which is why so many people believe they are "doing OAuth2" when they have written a normal login form. They have — and that is fine. Handing your username and password to an application is exactly what the rest of OAuth2 exists to avoid, so it is acceptable only when the application is the service that owns the password. StayHub's is, so it keeps one.
The password flow, and the button it turns on
One dependency and one endpoint. First the declaration:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token", auto_error=False)Then the endpoint it promises:
@router.post("/token", response_model=AuthResponse, dependencies=[LoginRateLimit])
def token(
form: Annotated[OAuth2PasswordRequestForm, Depends()], db: DbSession
) -> AuthResponse:
...
return AuthService(db).login(form.username, form.password)OAuth2PasswordRequestForm is a dependency, not a pydantic model, and it takes
application/x-www-form-urlencoded with fields named exactly username and
password. Both halves of that sentence bite:
$ curl -s -X POST localhost:8000/api/v1/auth/token \
-d "username=guest@stayhub.test&password=guest123"
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ…","tokenType":"bearer","user":{…}}
$ curl -s -X POST localhost:8000/api/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"username":"guest@stayhub.test","password":"guest123"}'
{"message":"Please check the highlighted fields.",
"fieldErrors":{"username":"Field required","password":"Field required"}} # HTTP 422StayHub keeps /auth/login alongside, taking JSON with an email field,
because its callers are two React apps. Two endpoints, one AuthService.login behind
them. That is not duplication worth removing — the spec fixes one shape and the frontends
want the other.
Declaring both security schemes is what makes the Authorize button work everywhere rather than only on the routes that use one of them:
{
"HTTPBearer": {
"type": "http",
"scheme": "bearer"
},
"OAuth2PasswordBearer": {
"type": "oauth2",
"flows": {
"password": {
"scopes": {},
"tokenUrl": "api/v1/auth/token"
}
}
}
}Sign in with Google, in two endpoints
Everything else in this lesson is the authorization code flow. Two routes, and the browser travels between them:
browser StayHub Google
│ │ │
│ GET /authorize │ │
├─────────────────────────>│ mint state + verifier │
│ │ store both in Redis │
│ 307 ──────────────────────────────────────────────> │
│ │ │ consent screen
│ 307 back with ?code=…&state=… │
│<─────────────────────────────────────────────────────┤
│ GET /callback?code&state│ │
├─────────────────────────>│ state known? spend it │
│ │ POST code + verifier ──> │
│ │ <── access_token │
│ │ GET userinfo ──────────> │
│ │ <── sub, email, verified │
│ │ link or create · mint OUR token
│ 307 to the app, token in the #fragment │
│<─────────────────────────┤ │Neither endpoint is called by JavaScript. Both are ordinary page navigations, because step two happens on Google's domain. That single fact explains most of the design decisions below, including why the token cannot simply be returned in a JSON body.
A provider is a handful of URLs and a pair of credentials:
def _google() -> Provider:
return Provider(
name="google",
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
# The OIDC userinfo endpoint, not the older `googleapis.com/oauth2/v2/userinfo`. This one
# returns the standard claim names — `sub`, `email`, `email_verified` — which is what the
# normaliser below expects and what every other OIDC provider also returns.
userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
scopes=("openid", "email", "profile"),
client_id=settings.oauth_google_client_id,
client_secret=settings.oauth_google_client_secret,
)state is not optional
Leave state out and the flow still works, all the way through, for everybody. It is
not a feature; it is a CSRF defence, and the attack is worth spelling out because it runs the wrong
way round from the one people expect.
The attacker signs in at Google as themselves and stops at the redirect,
keeping the code. They then get a victim's browser to visit
/callback?code=<the attacker's code>. Your server dutifully exchanges it, learns
the attacker's identity, and signs the victim's browser in as the attacker. The
victim is now working inside an account the attacker owns and reads — every address, saved
card and trip they add from that point on.
The fix is that a callback must carry a value you issued and remember. StayHub keeps what
matters on the server and lets only the opaque state travel through the browser:
class PendingLogin:
"""Everything the callback needs that must NOT travel through the browser.
`verifier` is the secret half of PKCE. `redirect_uri` is here because the token endpoint
demands the identical string it saw at the authorize step, and rebuilding it from the callback
request is how that drifts. `next_url` is where the user was going before we interrupted them.
"""
provider: str
verifier: str
redirect_uri: str
next_url: strprovider = oauth.get_provider(provider_name)
verifier = oauth.new_verifier()
redirect_uri = oauth.redirect_uri_for(provider_name)
state = oauth.remember(
oauth.PendingLogin(
provider=provider_name,
verifier=verifier,
redirect_uri=redirect_uri,
next_url=next if next.startswith("/") and not next.startswith("//") else "/",
)That produces this, which is the whole first half of the flow in one header:
HTTP 307 -> https://accounts.google.com/o/oauth2/v2/auth
response_type code
client_id 1084...apps.googleusercontent.com
redirect_uri http://localhost:8000/api/v1/auth/oauth/google/callback
scope openid email profile
state -ghwijF5bN-eimvYHSNrEMJZYzgUgTXLAipgiG0GFrI
code_challenge IrRrh2ewi706b3nwokPMIzNiKp1tLXDAOnrZmqGLsqI
code_challenge_method S256Note what is not in that URL: the verifier. Note also next, which arrives
from the browser and is forced to a path before it is stored. Passing it through unchecked is a
textbook open redirect — a link to
/authorize?next=https://stayhub-login.example/ walks somebody through a real StayHub
URL and a real Google consent screen and lands them on a copy of the site, with every step up to
that point looking exactly right. startswith("/") alone is not enough:
//evil.example has no scheme and is still absolute to a browser.
Spending it exactly once
def consume(state: str) -> PendingLogin | None:
...
client = _redis()
if client is None:
raise OAuthConfigurationError(
"Sign-in with a provider is temporarily unavailable.", status_code=503
)
try:
raw = client.getdel(_STATE_PREFIX + state)
except Exception as exc: # noqa: BLE001
logger.error("oauth: could not read login state: %s", exc)
raise OAuthConfigurationError(
"Sign-in with a provider is temporarily unavailable.", status_code=503
) from exc
if raw is None:
return None
return PendingLogin(**json.loads(raw))Two properties here, and both are load-bearing. The state is destroyed as it is read, so a callback cannot be replayed. And the read and the delete are one command:
def test_a_state_cannot_be_replayed(self, client):
state = self._start(client)
assert client.get(
f"/api/v1/auth/oauth/google/callback?code=abc&state={state}"
).status_code == 307
assert client.get(
f"/api/v1/auth/oauth/google/callback?code=abc&state={state}"
).status_code == 401Why this store fails closed
StayHub's cache treats a Redis outage as a permanent miss, and its rate limiter allows the
request — both deliberate, because an optimisation that can take the site down has stopped
being an optimisation. This store does the opposite and refuses. The difference is what the outage
costs: a missing cache entry costs a database read, while a missing state means you
cannot tell an authorisation you issued from one somebody forged. The only safe answer to "I cannot
verify this" is no.
Fail open here and a Redis blip becomes a silent hole in the login path — the exact class of bug nobody notices until it is in an incident report. Fail open for optimisations, fail closed for decisions.
PKCE, even though you are a server
PKCE was introduced for mobile and single-page apps that cannot keep a client secret, and the specification still describes it that way, so server-side applications skip it. Do not.
It binds the code to the browser that started the flow. Send a hash of a secret at the authorize
step; send the secret itself at the token step. Anywhere the code can leak — a
Referer header off the redirect page, browser history on a shared machine, a proxy log,
an open redirect elsewhere on your own domain — a leaked code is useless without the verifier,
which never left your server.
def new_verifier() -> str:
...
return secrets.token_urlsafe(64)
def challenge_for(verifier: str) -> str:
"""S256: base64url(sha256(verifier)), with the padding stripped.
⚠️ The `=` padding must go. base64url in these specs is unpadded, and Google rejects a padded
challenge with `invalid_grant` at the TOKEN step — one round trip after the mistake, on a
different endpoint, with an error that says nothing about padding.
"""
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")Two traps in six lines. The RFC permits a plain method where the verifier
is the challenge; it exists for clients that cannot compute SHA-256 and it protects
nothing, because anyone who intercepts the challenge has the verifier. And the base64url padding
has to go — a padded challenge is accepted at the authorize step and rejected one round trip
later at the token endpoint with invalid_grant, an error that says nothing about
padding.
Spending the code
def exchange_code(
provider: Provider, code: str, verifier: str, redirect_uri: str, client: httpx.Client
) -> str:
...
response = client.post(
provider.token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": provider.client_id,
"client_secret": provider.client_secret,
"code_verifier": verifier,
},
headers={"Accept": "application/json"},
)
if response.status_code != 200:
# The provider's body here says things like {"error": "invalid_grant"}, which is useful in
# a log and meaningless to a person staring at a sign-in button.
logger.warning(
"oauth: %s token exchange failed with %s: %s",
provider.name, response.status_code, response.text[:500],
)
raise UnauthorizedException("That sign-in could not be completed. Please try again.")
token = response.json().get("access_token")
if not token:
raise UnauthorizedException("That sign-in could not be completed. Please try again.")
return tokenThe redirect_uri is sent again, and it must be byte-identical to the one from the
authorize step — which is why it is stored in the pending login rather than rebuilt here.
It is also why it is built from configuration rather than from the request:
def redirect_uri_for(provider_name: str) -> str:
...
return f"{settings.oauth_redirect_base.rstrip('/')}/api/v1/auth/oauth/{provider_name}/callback"A redirect_uri assembled from request.url follows whatever host a
proxy — or an attacker — put in the Host header, and it has to match the
string registered in the provider's console anyway. Google compares that string literally:
http against https, or one stray slash, is
redirect_uri_mismatch and nothing more helpful.
The Accept header is there for GitHub, which answers its token endpoint with form
encoding unless asked otherwise — so response.json() raises a decode error on a
200, and the traceback points at your parsing rather than at the header you did not
send.
What the provider says about the email
# ⚠️ Google returns this as a real boolean, but plenty of providers send the string
# "true". `is True` would then be False and every login would be refused; truthiness on
# the string "false" would accept everything. Compare against both explicitly.
email_verified=profile.get("email_verified") in (True, "true", "True"),Providers disagree about types. Google sends a real boolean; others send the string
"true". is True then fails for everybody, and truthiness accepts the
string "false" — a bug that reads as correct and fails open.
The account takeover, and it is one if
Now the identity is in hand, and the only question left is which StayHub account it is. Three outcomes, and the order is the security.
First: have we seen this provider account before?
def _find_linked(self, identity: OAuthIdentity) -> User | None:
link = self.db.execute(
select(OAuthAccount).where(
OAuthAccount.provider == identity.provider,
OAuthAccount.subject == identity.subject,
)
).scalar_one_or_none()
return link.user if link else NoneLooked up by subject, not by email, and that choice is in the schema:
# ⚠️ The provider's `sub`, never the email. Addresses get reassigned — a company deletes
# alex@ and issues it to a new hire eighteen months later, and if the email is the join key
# that person signs straight into Alex's account, bookings and saved cards included. `sub` is
# the provider's permanent, opaque id for one account and it is the only safe thing to key on.
subject: Mapped[str] = mapped_column(String(255), nullable=False)Second: does a StayHub account already use that address? Here is the bug:
# ⚠️ THE account takeover, and it is one `if`.
#
# Without it: register a provider account using someone else's address, click "Sign in
# with <provider>", and the branch below matches an existing StayHub user by email and
# hands you their account — bookings, saved cards, trip history. Some providers will
# happily issue you an account on an address you have not proven you own; that is exactly
# what `email_verified` is telling you.
#
# Refusing outright, rather than falling through to "create a new account", is deliberate:
# creating one would put a second, unverified row on an address that already belongs to
# somebody, and the support ticket that follows is unanswerable.
if not identity.email or not identity.email_verified:
raise ForbiddenException(
"Your provider has not verified that email address, so we cannot sign you in "
"with it. Verify it with them, or sign in with your password."
)Without that check: register a provider account on somebody else's address, click the button,
and the branch below matches an existing user by email and hands you their account. Some providers
will happily issue an account on an address you have not proven you own — which is precisely
what email_verified is telling you.
Refusing outright, rather than falling through to "create a new account", is also deliberate. Creating one puts a second, unverified row on an address that already belongs to somebody, and the support ticket that follows has no good answer.
Third: create the account — with one detail that looks like a nicety and is not:
# ⚠️ A RANDOM hash, not a sentinel like "" or "!". The obvious move is a value
# that could never be a hash — and then `POST /auth/login` on that address reaches
# `verify_password`, passlib cannot identify the string as any known scheme, and
# it raises. The endpoint 500s instead of returning "email or password is
# incorrect", and the crash is reachable by anyone who guesses the address.
#
# Hashing 32 random bytes costs one bcrypt round at signup and makes the password
# path answer correctly and unremarkably: no password matches, ever.
password_hash=hash_password(secrets.token_urlsafe(32)),The obvious move is a sentinel that could never be a hash. Then POST /auth/login on
that address reaches verify_password, passlib cannot identify the string as any known
scheme, and it raises. The endpoint returns 500 instead of "email or password is
incorrect", and the crash is reachable by anyone who guesses the address. Hashing 32 random bytes
costs one bcrypt round at signup and makes the password path answer correctly and unremarkably.
One more thing lives in the schema rather than the code:
# ⚠️ On the PAIR. `subject` alone is not unique — Google and GitHub both number their own
# accounts from their own sequences, so a unique index on `subject` starts rejecting real
# logins the day the second provider is switched on, with a constraint error that names a
# column and explains nothing.
# Unnamed on purpose: `NAMING_CONVENTION` in db/base.py renders it
# `uq_oauth_accounts_provider`, and a hand-written name here would differ from what
# Alembic autogenerates — so every later `--autogenerate` proposes dropping and
# recreating a constraint that is already correct.
UniqueConstraint("provider", "subject"),Getting the token back to the browser
There is no fetch waiting for a response — the browser arrived here by
navigation. So the token has to ride on a redirect, and where it rides matters:
fragment = urlencode({"access_token": auth.access_token, "token_type": "bearer"})
destination = f"{settings.oauth_success_redirect}?{urlencode({'next': pending.next_url})}"
return RedirectResponse(f"{destination}#{fragment}", status_code=307)A fragment is never sent to a server. Not to StayHub, not in a Referer header to
whatever the landing page loads, not into an access log or a proxy's. A token in
?access_token= is written into every one of those, and into browser history on a
shared machine besides.
def test_the_token_travels_in_the_fragment(self, client):
state = self._start(client)
location = client.get(
f"/api/v1/auth/oauth/google/callback?code=abc&state={state}"
).headers["location"]
parsed = urlparse(location)
# In the fragment, which no browser sends to any server...
assert "access_token=" in parsed.fragment
# ...and NOT in the query string, which every access log and Referer header records.
assert "access_token" not in parse_qs(parsed.query)This is still the demo's simplification. A production deployment sets an
HttpOnly cookie, or hands back a one-time code the app exchanges for a token —
both of which keep it out of JavaScript's reach entirely. The fragment is the best of the options
that do not.
And one branch that is not an edge case:
if error:
return RedirectResponse(
f"{settings.oauth_success_redirect}?{urlencode({'error': error})}", status_code=307
)Every provider sends the user to your callback with ?error=access_denied when they
press Cancel on the consent screen. A handler that only reads code treats that as a
malformed callback and shows a crash to somebody who simply changed their mind.
Testing a flow that leaves your network
The reflex is to monkeypatch exchange_code. Do not: that replaces the function
under test, and the request body is exactly where these bugs live. Take the HTTP client as a
dependency instead —
def http_client():
...
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
yield client— and a test can answer the provider locally while the real code builds the real request:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "oauth2.googleapis.com":
seen["token_body"] = dict(
pair.split("=", 1) for pair in request.content.decode().split("&")
)
seen["token_accept"] = request.headers.get("accept")
if token_status != 200:
return httpx.Response(token_status, json={"error": "invalid_grant"})
return httpx.Response(200, json={"access_token": "google-access-token"})
if request.url.host == "openidconnect.googleapis.com":
seen["userinfo_auth"] = request.headers.get("authorization")
return httpx.Response(200, json=userinfo)Forty-four tests run against that, with no network and no credentials. The four that matter most
were each confirmed by reintroducing the bug they describe: dropping the
email_verified check, turning GETDEL back into GET, passing
next through unchecked, and leaving the base64 padding on the challenge. Every one of
them failed exactly two tests and nothing else. A security test you have never seen fail is a
security test you do not have.
The mistakes that actually ship
Trusting the provider's access token as identity. It is a key to their API, not a statement about a person. Exchange it for your own token and forget it.
Keying the account on the email. Addresses get reassigned — a company
deletes alex@ and issues it to a new hire eighteen months later. If the email is the
join key, that person signs straight into Alex's account. sub is the only safe
key.
A unique index on subject alone. Fine until the second provider is
switched on, at which point real logins start failing on a constraint error that names a column and
explains nothing.
Skipping state because the flow works without it. It does. That is
the problem.
Putting the token in the query string. Referer headers, access logs, browser history — and unlike a leaked password, nobody gets told to rotate it.
Storing the provider's refresh token when you do not use its API. StayHub reads one profile and never calls Google again, so it keeps nothing. A stored refresh token is a long-lived credential to somebody else's service sitting in your database for no reason.
Practical decisions
Do not implement OIDC yourself if you can help it. This lesson does, because
watching the round trip is the point of it. In production, a library that validates the
id_token signature against the provider's rotating JWKS is worth the dependency
— StayHub sidesteps that by reading userinfo over TLS instead, which is simpler
and one more round trip.
Let one person have several providers. A separate table, not two columns on
users. Columns allow exactly one provider, and the migration to a second is the one
nobody budgets for.
Keep the password path. Providers have outages, people lose access to accounts, and an app that can only be entered through somebody else's login is an app you cannot support.
Ask for the fewest scopes that answer the question. openid email
profile is enough to know who somebody is. Every scope past that is a consent screen that
asks for more and converts worse.
Next: file uploads — the endpoint where user input stops being JSON and starts being bytes on your disk.