OAuth2 with Real Providers
1 · The lesson
readExamples shown in Flask/FastAPI shape. Run locally with the framework installed; auth flows need a running server. Expected output shown in comments.
"Sign in with Google." Click, accept a consent screen, and you're logged into a third-party app without ever giving Google your password to that app. The plumbing under that one-click flow is OAuth 2.0 — a delegated-authorisation protocol that lets your app act on a user's behalf at another service. Combined with OpenID Connect (OIDC), it becomes the standard "log in with X" pattern that powers most of the modern web.
This lesson is the working knowledge — the flow, the security-critical pieces (state, PKCE, redirect_uri whitelist), the library to use, and the gotcha that catches every first-time integrator: OAuth is authorisation, not authentication. OIDC adds authentication.
1. The Mental Model
OAuth answers the question: "How can a user grant my app limited access to their account at another service, without giving my app their password?"
The cast:
- Resource owner — the user.
- Client — your app.
- Authorisation server — Google/GitHub/whoever (the place the user has an account).
- Resource server — the API holding the user's data (often the same provider).
Your app never sees the user's Google password. Google asks the user "do you want to let myapp see your email address?", the user clicks yes, and your app gets a short-lived access token good for exactly the scopes the user approved.
2. The Authorisation Code Flow
The flow used by every server-side web app today. Five steps:
1. User clicks "Sign in with Google" on your site.
2. Your server redirects browser to:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=<your-id>
&redirect_uri=https://myapp.com/auth/google/callback
&response_type=code
&scope=openid email profile
&state=<random-csrf-token>
3. User signs into Google (if not already), sees the consent screen, clicks Allow.
4. Google redirects browser back to your callback:
https://myapp.com/auth/google/callback?code=<short-lived-code>&state=<same-token>
5. Your SERVER POSTs to Google's token endpoint:
POST https://oauth2.googleapis.com/token
client_id=<your-id>
client_secret=<your-secret>
code=<the-code>
redirect_uri=<must-match-step-2>
grant_type=authorization_code
← Google returns:
{ access_token, refresh_token, id_token, expires_in }
6. Your server now has the tokens — use access_token to call Google APIs,
parse id_token (a JWT) to know who the user is.The key security property: the code is short-lived (a minute or so) and single-use, and the token exchange (step 5) is server-to-server with your client secret — even an attacker who intercepts the redirect can't trade the code for tokens without your secret.
3. Why "Authorization Code" Replaced "Implicit"
OAuth 2.0 originally defined an Implicit grant for browser-side apps (SPAs, mobile) where the access token came back directly in the URL fragment. No code exchange, no client secret — and no protection against token leakage via browser history, referer headers, or malicious extensions.
The 2019 OAuth 2.0 Security BCP and OAuth 2.1 officially retired Implicit. The modern replacement is Authorization Code with PKCE (RFC 7636) — the auth code flow, plus:
- Client generates a random
code_verifierand its SHA-256 hash,code_challenge. - The challenge is sent on the initial redirect.
- The verifier is sent on the token exchange.
- The provider checks they match — so even if someone steals the
code, they can't exchange it without the verifier (which never leaves the original client).
PKCE is now mandatory for public clients (SPAs, mobile, desktop) and recommended for everyone. Modern OAuth libraries (including authlib) wire it in by default.
4. Provider Setup — One-Time, Per Provider
For each provider you want to support:
1. Register an app at the provider's developer console (Google: Cloud Console; GitHub: Settings → Developer settings → OAuth Apps).
2. You get back a client ID and a client secret. The secret goes in env vars, never in code — see envconfig.
3. You register one or more redirect URIs (e.g. https://myapp.com/auth/google/callback). The provider strictly matches the redirect URI on every request — an exact string match. This is the firewall against open-redirect attacks where an attacker tries to trick the provider into sending the code somewhere else.
4. You pick the scopes your app needs (more on this in Section 6).
For Google specifically: enable the relevant APIs in the Cloud Console (e.g., People API for profile reads), set the OAuth consent screen, and add the redirect URIs to the OAuth client.
5. The state Parameter — CSRF on the Callback
Without state, an attacker can trick a logged-in user into a callback that links the attacker's identity to the victim's session. The fix is dead simple: generate a random value, store it in the user's session before redirecting, check it matches when the callback fires.
import secrets from flask import session, redirect, request, abort @app.get("/auth/google/login") def google_login(): state = secrets.token_urlsafe(32) session["oauth_state"] = state auth_url = build_auth_url(state=state, ...) return redirect(auth_url) @app.get("/auth/google/callback") def google_callback(): if request.args.get("state") != session.pop("oauth_state", None): abort(400, "state mismatch — possible CSRF") code = request.args.get("code") # ...exchange code for tokens
One use; never accept the same state twice. The session.pop invalidates it after one read.
6. scope — Ask For The Least
Scopes are the granular permissions the user must consent to. Ask only for what you need. Three reasons:
- Users decline if you ask for too much. "Sign in with X" abandonment skyrockets the moment your consent screen says "and access your contacts and your calendar."
- Reviewers reject. Google reviews apps that request sensitive scopes; over-asking triggers a verification process that takes weeks.
- Breach blast radius. If your tokens leak, an attacker has exactly the scopes you asked for. Don't be the app that requested
https://www.googleapis.com/auth/drivefor a forum login.
Common scopes:
| Scope | What it gives you |
|---|---|
openid | OIDC — required for any "log in with X" |
email | The user's email address |
profile | Basic profile (name, picture) |
https://www.googleapis.com/auth/calendar.readonly | Read-only calendar access |
For pure sign-in, openid email profile is the entire scope set.
7. Token Refresh — For Long-Running Apps
If your app calls the provider's API hours or days after the user signed in, you'll need a refresh token:
# Step 5 returned: # { "access_token": "...", "refresh_token": "...", "expires_in": 3600 } # Hours later, access_token has expired. Exchange the refresh_token: import httpx r = httpx.post("https://oauth2.googleapis.com/token", data={ "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "refresh_token": stored_refresh_token, "grant_type": "refresh_token", }) new_access = r.json()["access_token"]
setup added so this can run · defines CLIENT_ID, CLIENT_SECRET, stored_refresh_token
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) CLIENT_ID = _AutoMock('CLIENT_ID') CLIENT_SECRET = _AutoMock('CLIENT_SECRET') stored_refresh_token = _AutoMock('stored_refresh_token')
Refresh tokens are sensitive — they're long-lived and effectively let you mint new access tokens at will. Store them server-side, encrypted at rest if possible, and never hand them to the browser. For Google, you only get a refresh token if you pass access_type=offline (and usually prompt=consent to force the consent screen on every login during dev).
8. Linking an OAuth Identity to Your User Table
The user signed in via Google. Now what — is it a new user, an existing one, or a Google identity being attached to an existing email-password account?
Two strategies:
By email match.
google_email = id_token_claims["email"] existing = users.find_by_email(google_email) if existing: link_social_identity(existing.id, provider="google", subject=id_token_claims["sub"]) else: user = users.create(email=google_email, password=None) # no password set link_social_identity(user.id, provider="google", subject=id_token_claims["sub"]) login(user)
setup added so this can run · defines id_token_claims, login, users, link_social_identity
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) id_token_claims = _AutoMock('id_token_claims') def login(*_a, **_kw): print('-> login() called') return _AutoMock('login()') users = _AutoMock('users') def link_social_identity(*_a, **_kw): print('-> link_social_identity() called') return _AutoMock('link_social_identity()')
Simple, but trusts that the provider verified the email. Google does (look at email_verified in the ID token claims); GitHub returns multiple emails some of which may be unverified — check carefully.
Always create new + offer linking.
Safer for multi-provider apps where one user might sign in with Google one day and GitHub another. Use a social_logins table:
CREATE TABLE social_logins (
user_id BIGINT REFERENCES users(id),
provider TEXT NOT NULL, -- 'google', 'github', ...
subject TEXT NOT NULL, -- provider's stable user ID (id_token.sub)
email TEXT,
PRIMARY KEY (provider, subject)
);On login: look up by (provider, subject). If found, log that user in. If not, prompt to log in with another method to link, or create a new user.
Use subject (the provider's sub claim), not email — emails can change. sub is stable for the life of the account at that provider.
9. OAuth Is Not Authentication — OIDC Is
OAuth 2.0 was designed to answer "can this app do X on the user's behalf?" — authorisation. It was not designed to answer "who is this user?" — authentication. Using a raw OAuth access token as proof of identity is a known anti-pattern: an attacker who steals a token from another app with the same scopes can replay it against yours.
OpenID Connect (OIDC) is the standardised authentication layer on top of OAuth. The two key additions:
- A new scope:
openid. Requesting it asks the provider to also issue an ID token. - The ID token — a JWT (yes, the auth-jwt kind) signed by the provider, with claims including
iss,sub,aud,email,name,picture, etc.
Your app validates the ID token's signature (against the provider's published JWKS), checks iss, aud, exp, and trusts the sub as the user's identity. That's how you do "Sign in with X". The access token is for calling APIs; the ID token is for knowing who's logged in.
When you see scope=openid email profile, that openid is what unlocks OIDC behaviour. Without it, the provider treats your request as plain OAuth, no ID token issued.
10. Library Tour — Don't Roll Your Own
The list, with the use case for each:
| Library | Best for |
|---|---|
authlib | The general-purpose choice. Flask, FastAPI, Django, Starlette — all integrations live here. Handles auth code, PKCE, OIDC, refresh, the lot. |
requests-oauthlib | Older, lower-level, still common. Fine if you already have it. |
python-social-auth | Django-specific, dozens of providers pre-wired. |
Authlib for FastAPI | Same authlib package, with FastAPI integration. |
Use authlib unless you have a strong reason. The "I'll just write the redirect and the token exchange myself" approach forgets PKCE, mishandles state, picks the wrong aud to validate, and ships a CVE. Libraries exist because every careful implementation has already been written.
11. A Complete Flask Example with authlib
pip install authlib flask
import os from flask import Flask, redirect, url_for, session, abort, request from authlib.integrations.flask_client import OAuth app = Flask(__name__) app.secret_key = os.environ["FLASK_SECRET"] # for the session cookie oauth = OAuth(app) oauth.register( name="google", client_id=os.environ["GOOGLE_CLIENT_ID"], client_secret=os.environ["GOOGLE_CLIENT_SECRET"], server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile"}, ) @app.get("/auth/google/login") def google_login(): redirect_uri = url_for("google_callback", _external=True) # authlib generates `state` and (for public clients) PKCE for you return oauth.google.authorize_redirect(redirect_uri) @app.get("/auth/google/callback") def google_callback(): try: token = oauth.google.authorize_access_token() # validates state, exchanges code except Exception as e: # authlib's MismatchingStateError etc. abort(400, f"oauth callback failed: {e}") userinfo = token.get("userinfo") or oauth.google.userinfo(token=token) # userinfo is the parsed ID token claims: # { "sub": "1234567890", "email": "user@example.com", "email_verified": true, # "name": "...", "picture": "..." } if not userinfo.get("email_verified"): abort(400, "email not verified at provider") user = upsert_user_from_oauth( provider="google", subject=userinfo["sub"], email=userinfo["email"], name=userinfo.get("name"), ) session.clear() # mitigate fixation, see auth-passwords session["user_id"] = user.id return redirect("/dashboard")
setup added so this can run · defines upsert_user_from_oauth
import os # noqa: F401 os.environ.setdefault("FLASK_SECRET", "example-flask-secret") os.environ.setdefault("GOOGLE_CLIENT_ID", "example-google-client-id") os.environ.setdefault("GOOGLE_CLIENT_SECRET", "example-google-client-secret") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def upsert_user_from_oauth(*_a, **_kw): print('-> upsert_user_from_oauth() called') return _AutoMock('upsert_user_from_oauth()')
What authlib is doing for you, that you'd otherwise have to write:
- Generating + storing
state, verifying it on the callback. - Generating PKCE verifier/challenge for public clients.
- Fetching the provider's discovery document (
/.well-known/openid-configuration) so you don't hardcode every endpoint URL. - Fetching the provider's JWKS and verifying the ID token signature.
- Validating
iss,aud,expon the ID token.
That's a few hundred lines of crypto-adjacent code you didn't have to write or test. Use the library.
12. Logout
Two different things called "logout":
- Clear your own session. Always do this.
session.clear(), expire the auth cookie. - Revoke the provider's tokens. Most apps don't bother — the user is logging out of your app, not their Google account. If the user wants to revoke your app entirely, they do it in their Google account settings.
If you genuinely need to revoke a token (e.g., user clicks "disconnect Google"), most providers have a revocation endpoint:
import httpx httpx.post("https://oauth2.googleapis.com/revoke", params={"token": stored_refresh_token})
setup added so this can run · defines stored_refresh_token
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) stored_refresh_token = _AutoMock('stored_refresh_token')
Then delete the row from social_logins. Future "Sign in with Google" attempts will trigger a fresh consent flow.
13. Common Mistakes
1. Skipping state. Without it, the callback is forgeable — an attacker can link their identity to your session. authlib handles it; if you're hand-rolling, generate, store, verify, single-use.
2. Storing the access token in localStorage to call APIs from JS. Same XSS-exfiltration problem as JWTs. Proxy provider calls through your backend, which holds the tokens server-side.
3. Requesting too many scopes. Users decline, reviewers reject, breaches expand. Ask for openid email profile for sign-in; add other scopes only when you need them and explain why.
4. Not handling the error callback. When the user clicks "Cancel" on the consent screen, the provider redirects back with ?error=access_denied, not ?code=.... Your callback must handle both. authlib raises an OAuthError; check for it.
5. Trusting unverified emails. Google's email_verified claim is reliable. GitHub doesn't have it on the basic profile; you have to call the emails endpoint and look for the primary verified one. Always check before linking by email.
6. Treating OAuth as authentication. Raw OAuth access tokens are not identity proofs. Use OIDC — request openid scope, validate the ID token, trust the sub claim.
7. Hardcoding endpoint URLs. Providers rotate them. Use the OIDC discovery document (/.well-known/openid-configuration) — authlib reads it for you.
8. Sharing redirect_uri between dev and prod. Register both (http://localhost:8000/auth/google/callback and https://myapp.com/auth/google/callback) in the provider console. Production should be HTTPS-only.
🎯 Your Turn — A FastAPI Google Sign-In Endpoint Pair
Write two FastAPI endpoints:
1. GET /auth/google/login — builds the Google authorisation URL with scope, redirect_uri, and a random state; stores state in the session; redirects.
2. GET /auth/google/callback — verifies the state matches, exchanges the code for tokens via authlib, returns the user info.
Use the placeholder credentials from environment variables; the solution shows where to plug in real ones from the Google Cloud Console.
Skeleton:
import os from fastapi import FastAPI, Request, HTTPException from fastapi.responses import RedirectResponse from authlib.integrations.starlette_client import OAuth from starlette.middleware.sessions import SessionMiddleware app = FastAPI() app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"]) oauth = OAuth() # TODO 1: oauth.register("google", ...) — discovery URL + scopes @app.get("/auth/google/login") async def google_login(request: Request): # TODO 2: build redirect_uri pointing at the callback # TODO 3: oauth.google.authorize_redirect(request, redirect_uri) ... @app.get("/auth/google/callback") async def google_callback(request: Request): # TODO 4: oauth.google.authorize_access_token(request) # TODO 5: extract userinfo, return it (or upsert + log user in) ...
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("SESSION_SECRET", "example-session-secret")
Hint 1 — Discovery URL means no hardcoded endpoints
Passserver_metadata_url="https://accounts.google.com/.well-known/openid-configuration" to oauth.register. authlib fetches it once and learns the authorisation, token, JWKS, and userinfo endpoints from it. The day Google rotates an endpoint, your code keeps working.
Hint 2 — authorize_redirect does the heavy lifting
You don't build the URL yourself. oauth.google.authorize_redirect(request, redirect_uri) generates state, stashes it in the session, builds the URL with all params, and returns a redirect response. On the callback, oauth.google.authorize_access_token(request) validates state, exchanges the code, validates the ID token signature against Google's JWKS, and gives you back the token dict.
Show full solution
""" Google sign-in for FastAPI with authlib. Setup (one-time, per environment): 1. https://console.cloud.google.com → APIs & Services → Credentials 2. Create OAuth Client ID, type "Web application" 3. Authorised redirect URI: http://localhost:8000/auth/google/callback (and your prod URL too) 4. Copy Client ID + Client Secret into env vars Env vars required: SESSION_SECRET — long random string for cookie signing GOOGLE_CLIENT_ID — from the Cloud Console GOOGLE_CLIENT_SECRET — from the Cloud Console Run: uvicorn this_file:app --reload open http://localhost:8000/auth/google/login """ import os from fastapi import FastAPI, Request, HTTPException from fastapi.responses import RedirectResponse, JSONResponse from authlib.integrations.starlette_client import OAuth, OAuthError from starlette.middleware.sessions import SessionMiddleware app = FastAPI() app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"]) oauth = OAuth() oauth.register( name="google", client_id=os.environ["GOOGLE_CLIENT_ID"], client_secret=os.environ["GOOGLE_CLIENT_SECRET"], server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile"}, ) @app.get("/auth/google/login") async def google_login(request: Request): """Kick off the Google OAuth dance.""" redirect_uri = request.url_for("google_callback") # authlib generates `state` (and PKCE if applicable), stores in session, returns 302 return await oauth.google.authorize_redirect(request, str(redirect_uri)) @app.get("/auth/google/callback") async def google_callback(request: Request): """Receive the redirect from Google, exchange code, return user info.""" try: token = await oauth.google.authorize_access_token(request) except OAuthError as e: # Covers state mismatch, user denial, network errors, etc. raise HTTPException(status_code=400, detail=f"oauth callback failed: {e.error}") # authlib already validated the ID token signature against Google's JWKS, # checked iss/aud/exp, and parsed the claims into token["userinfo"]. userinfo = token.get("userinfo") if userinfo is None: # Fallback: hit the userinfo endpoint resp = await oauth.google.userinfo(token=token) userinfo = dict(resp) if not userinfo.get("email_verified"): raise HTTPException(status_code=400, detail="email not verified at provider") # In a real app: # user = upsert_user(provider="google", subject=userinfo["sub"], email=userinfo["email"]) # request.session.clear() # request.session["user_id"] = user.id # return RedirectResponse("/dashboard") return JSONResponse({ "subject": userinfo["sub"], "email": userinfo["email"], "name": userinfo.get("name"), "picture": userinfo.get("picture"), }) # Demo run: # $ uvicorn solution:app --reload # browser → http://localhost:8000/auth/google/login # → redirected to Google consent screen # → click Allow # → redirected back to /auth/google/callback # → JSON response: # { # "subject": "10732547283645017263", # "email": "you@example.com", # "name": "Your Name", # "picture": "https://lh3.googleusercontent.com/..." # }
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("SESSION_SECRET", "example-session-secret") os.environ.setdefault("GOOGLE_CLIENT_ID", "example-google-client-id") os.environ.setdefault("GOOGLE_CLIENT_SECRET", "example-google-client-secret")
What this gets right:
- Discovery URL — every Google endpoint URL learned at startup. Rotation-safe.
authorize_redirect—stategeneration, session storage, PKCE all handled internally.authorize_access_token— verifiesstate, exchangescode, validates the ID token signature against Google's JWKS, checksiss/aud/exp, parses claims. One line, doing what would otherwise be ~80 lines of careful crypto-adjacent code.OAuthErrorcaught explicitly — covers state mismatch, user denial (error=access_denied), and network errors with one handler.email_verifiedchecked — never link by an unverified email; that path leads to account-takeover bugs.- Comments showing the production extras — session fixation defence (
request.session.clear()), the upsert step, and the post-login redirect.
What's intentionally elided:
- The
upsert_user_from_oauthfunction — covered conceptually in Section 8 above; in real code it's a DB write to your users + social_logins tables. - Refresh tokens — Google only issues them with
access_type=offline+prompt=consent. Add viaauthorize_redirect(..., access_type="offline", prompt="consent")when your app needs long-running API access. - Multi-provider support — register additional
oauth.register(...)blocks for GitHub, Microsoft, etc. The endpoint pair is essentially the same shape for each.
The shape is the production pattern. Swap the provider name and credentials, and you have GitHub sign-in too.
What You Learned
- OAuth 2.0 = delegated authorisation: your app gets tokens to act on a user's behalf without ever seeing their password.
- The modern flow is Authorization Code with PKCE. Implicit is retired; never use it.
stateis mandatory — random, session-stored, single-use, checked on the callback. Prevents CSRF.scope: ask for the least.openid email profileis the entire scope set for plain sign-in.- Refresh tokens are long-lived and server-side only. Never expose them to the browser.
- Link by
sub, not email —subis the provider's stable identifier; emails change. - OAuth ≠ authentication. OIDC (just OAuth + the
openidscope + an ID token) is what powers "Sign in with X". - Use
authlib— handlesstate, PKCE, discovery, JWKS, ID-token validation. Don't hand-roll. - Endpoints come from the OIDC discovery document, not hardcoded constants. Rotation-safe.
- On callback, handle
error(user denied) andstate mismatch— both arrive at the same URL but neither yields acode.
Next: Web Security Checklist — the OWASP-Top-10-flavoured pass over a Python web app, covering injection, IDOR, CSRF, XSS, dependencies, and the security headers you should be sending today.