PythonMastery
intermediate 22 min read · lesson 3 of 4 in Auth & Security

OAuth2 with Real Providers

1 · The lesson

read

The examples use Flask or FastAPI. Run them locally with the framework installed, because auth flows need a running server. Expected output is shown in comments.

"Sign in with Google." You click, accept a consent screen, and you're logged into a third-party app without ever giving your Google password to that app. The protocol behind this 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 used across most of the modern web.

This lesson covers what you need to build it: the flow, the security-critical pieces (state, PKCE, the redirect_uri allowlist), the library to use, and the point that confuses most developers the first time: OAuth is for 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 four roles:

  • Resource owner: the user.
  • Client: your app.
  • Authorisation server: Google, GitHub or another provider (the place where 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 that is valid only for the scopes the user approved.


2. The Authorisation Code Flow

This is the flow most server-side web apps use today. It has six steps:

text
1. User clicks "Sign in with Google" on your site.
2. Your server redirects the 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 the 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. It uses access_token to call Google APIs,
   and parses 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 a server-to-server request that includes your client secret. Even an attacker who intercepts the redirect can't exchange 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. There was no code exchange and no client secret, so nothing protected the token from leaking through browser history, referer headers or malicious extensions.

The OAuth 2.0 Security Best Current Practice (RFC 9700, January 2025) says clients should not use Implicit, and the OAuth 2.1 draft removes it completely. The modern replacement is Authorization Code with PKCE (RFC 7636): the auth code flow, plus these steps:

  • Client generates a random code_verifier and 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 that 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. authlib adds it when you ask: put "code_challenge_method": "S256" in client_kwargs (it is not on by default).


4. Provider Setup: Once 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 checks the redirect URI on every request with an exact string match. This protects 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 simple: generate a random value, store it in the user's session before redirecting, and check that it matches when the callback arrives.

python
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():
    expected = session.pop("oauth_state", None)
    if not expected or not secrets.compare_digest(request.args.get("state", ""), expected):
        abort(400, "state mismatch: possible CSRF")
    code = request.args.get("code")
    # ...exchange code for tokens

One use only. Never accept the same state twice. The session.pop invalidates it after one read, and not expected rejects a callback that arrives when no login was started. Without that check, a simpler test such as request.args.get("state") != expected would compare None with None and let the request through.


6. scope: Ask for the Minimum

Scopes are the detailed permissions the user must agree to. Ask only for what you need, for three reasons:

  • Users decline if you ask for too much. Far more users abandon "Sign in with X" when your consent screen says "and access your contacts and your calendar."
  • Reviewers reject. Google reviews apps that request sensitive scopes, and asking for too much triggers a verification process that takes weeks.
  • Damage from a breach. If your tokens leak, an attacker gets exactly the scopes you asked for. Don't be the app that requested https://www.googleapis.com/auth/drive for a forum login.

Common scopes:

ScopeWhat it gives you
openidOIDC: required for any "log in with X"
emailThe user's email address
profileBasic profile (name, picture)
https://www.googleapis.com/auth/calendar.readonlyRead-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:

python
# 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 let the holder create new access tokens whenever they want. 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. What now? 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.

python
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), but GitHub returns multiple emails, and some of them may be unverified, so 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:

sql
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, because 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 bad practice: 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 (the same kind as in auth-jwt) 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 is how "Sign in with X" works. The access token is for calling APIs, and the ID token is for knowing who's logged in.

When you see scope=openid email profile, the openid scope is what turns on OIDC behaviour. Without it, the provider treats your request as plain OAuth and issues no ID token.


10. Libraries: Don't Write Your Own

The list, with the use case for each:

LibraryBest for
authlibThe general-purpose choice. It has integrations for Flask, FastAPI, Django and Starlette. Handles auth code, PKCE, OIDC, refresh and more.
requests-oauthlibOlder, lower-level, still common. Fine if you already have it.
python-social-authDozens of providers already configured. Supports several frameworks and is used mostly with Django.
Authlib for FastAPISame authlib package, with FastAPI integration.

Use authlib unless you have a strong reason. When developers write the redirect and the token exchange themselves, they often forget PKCE, handle state incorrectly, validate the wrong aud, and ship a security vulnerability. A well-tested library has already solved these problems.


11. A Complete Flask Example with authlib

bash
pip install authlib flask
python
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", "code_challenge_method": "S256"},  # S256 turns on PKCE
)


@app.get("/auth/google/login")
def google_login():
    redirect_uri = url_for("google_callback", _external=True)
    # authlib generates `state`, and PKCE because of code_challenge_method above
    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:                                   # for example authlib's MismatchingStateError
        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()                                          # prevents session fixation (see the passwords lesson)
    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 the PKCE verifier/challenge (because code_challenge_method="S256" is set).
  • 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, exp on the ID token.

That is a lot of security-critical code you don'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 do this, because 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 really need to revoke a token (e.g., user clicks "disconnect Google"), most providers have a revocation endpoint:

python
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 can be forged: an attacker can link their identity to your session. authlib handles it. If you write the flow yourself: generate it, store it, verify it, and use it only once.

2. Storing the access token in localStorage to call APIs from JS. Same XSS token-theft problem as with JWTs. Proxy provider calls through your backend, which holds the tokens server-side.

3. Requesting too many scopes. Users decline, reviewers reject your app, and breaches do more damage. 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, so check for it.

5. Trusting unverified emails. Google's email_verified claim is reliable. GitHub doesn't have it on the basic profile, so 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 do not prove identity. Use OIDC: request the openid scope, validate the ID token, trust the sub claim.

7. Hardcoding endpoint URLs. Providers can change 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, and 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 add real ones from the Google Cloud Console.

Skeleton:

python
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", ...) with discovery URL, scopes, code_challenge_method="S256" (PKCE)

@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: get userinfo and return it (or save the user and log them 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 Pass server_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. If Google changes an endpoint, your code keeps working.
Hint 2: authorize_redirect does most of the work You don't build the URL yourself. oauth.google.authorize_redirect(request, redirect_uri) generates state, stores 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
python
"""
Google sign-in for FastAPI with authlib.

Setup (one-time, per environment):
  1. In https://console.cloud.google.com, open APIs & Services, then Credentials
  2. Create OAuth Client ID, type "Web application"
  3. Authorised redirect URI: http://localhost:8000/auth/google/callback
     (and your production URL too)
  4. Copy the Client ID and Client Secret into env vars

Env vars required:
  SESSION_SECRET:       a long random string for signing the cookie
  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", "code_challenge_method": "S256"},  # S256 turns on PKCE
)


@app.get("/auth/google/login")
async def google_login(request: Request):
    """Start the Google OAuth sign-in flow."""
    redirect_uri = request.url_for("google_callback")
    # authlib generates `state` and PKCE (S256, set in client_kwargs), stores them in the 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 the code, return user info."""
    try:
        token = await oauth.google.authorize_access_token(request)
    except OAuthError as e:
        # Covers state mismatch, user denial, network errors, and so on.
        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: call 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
#   open in the 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 is read at startup, so the code keeps working if they change.
  • authorize_redirect: state generation, session storage, PKCE (turned on by code_challenge_method="S256") all handled internally.
  • authorize_access_token: verifies state, exchanges code, validates the ID token signature against Google's JWKS, checks iss/aud/exp, and parses the claims. One line replaces a lot of careful security-critical code.
  • OAuthError caught explicitly: covers state mismatch, user denial (error=access_denied), and network errors with one handler.
  • email_verified checked: never link by an unverified email, because that can lead 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 left out:

  • The upsert_user_from_oauth function: the idea is covered 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 via authorize_redirect(..., access_type="offline", prompt="consent") when your app needs long-running API access.
  • Multi-provider support: add more oauth.register(...) blocks for GitHub, Microsoft, etc. The two endpoints look almost the same for each provider.

This structure is the production pattern. Change 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, so never use it.
  • state is mandatory: random, stored in the session, single-use, checked on the callback. Prevents CSRF.
  • scope: ask for the least. openid email profile is 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: sub is the provider's stable identifier, but emails change.
  • OAuth is not authentication. OIDC (just OAuth + the openid scope + an ID token) is what powers "Sign in with X".
  • Use authlib: it handles state, PKCE, discovery, JWKS and ID-token validation. Don't write your own.
  • Endpoints come from the OIDC discovery document, not hardcoded constants, so changed endpoints don't break your code.
  • On the callback, handle error (user denied) and state mismatch. Both arrive at the same URL but neither yields a code.

Next: Web Security Checklist: a review of a Python web app based on the OWASP Top 10, covering injection, IDOR, CSRF, XSS, dependencies, and the security headers you should be sending today.

Found this useful? Share it with a friend who’s learning Python.