Contents

How to Implement OAuth 2.0 Login from Scratch

You implement OAuth 2.0 login by using the Authorization Code flow with PKCE (Proof Key for Code Exchange). Your web app redirects the user to the provider’s authorization endpoint with a code_challenge, the user authenticates and consents, the provider redirects back with an authorization code, and your backend exchanges that code along with the code_verifier for an access token. PKCE is mandatory for all OAuth 2.0 clients under the OAuth 2.1 draft specification (currently at draft-ietf-oauth-v2-1-15) and eliminates the need for a client secret in public clients. Building this from scratch - without Auth0, Clerk, or NextAuth - takes roughly 200 lines of code and teaches you exactly how token exchange, session management, and token refresh actually work.

This post walks through the full implementation using Flask (current stable version 3.1.x) and GitHub as the OAuth provider. GitHub added PKCE support for OAuth and GitHub App authentication in July 2025 , making it a solid choice for learning the modern flow.

OAuth 2.0 Core Concepts and Why PKCE Is Now Required

Before writing any code, you need a clear mental model of the actors, grants, and token types involved.

Every OAuth 2.0 flow involves four actors. The Resource Owner is the user - the person who owns the data. The Client is your application - the thing requesting access. The Authorization Server is the provider’s login system (GitHub, Google, etc.) that authenticates the user and issues tokens. The Resource Server is the API that holds user data and accepts access tokens.

The original Authorization Code flow worked like this: your client redirects to the authorization server, the user logs in and consents, the authorization server redirects back with a code, and your client exchanges that code plus a client_secret for an access token. The problem is that the client_secret must live on the client. For server-rendered apps, that is fine - the secret stays on the server. For single-page applications and mobile apps, there is no secure place to store a secret. Any value bundled into a JavaScript bundle or mobile binary can be extracted.

PKCE (RFC 7636, pronounced “pixy”) fixes this. Instead of relying on a shared secret, the client generates a random code_verifier (a string between 43 and 128 characters), hashes it with SHA-256 to produce a code_challenge, and sends the challenge with the authorization request. When exchanging the code for tokens, the client sends the original code_verifier. The authorization server hashes it and checks that it matches the challenge. This proves the same client that started the flow is the one finishing it - no shared secret needed.

The OAuth 2.1 draft consolidates a decade of security best practices into a single specification. It makes PKCE mandatory for all clients (public and confidential), removes the implicit grant entirely, deprecates the resource owner password credentials grant, and requires exact string matching for redirect URIs. If you are building anything in 2026, always use Authorization Code with PKCE.

Three token types show up in OAuth flows. An access token is short-lived (typically one hour) and used to call APIs on behalf of the user. A refresh token is long-lived and used to obtain new access tokens without requiring the user to log in again. An ID token is an OpenID Connect addition - a JWT containing identity claims like the user’s name and email. GitHub’s OAuth implementation does not issue ID tokens (it is not a full OIDC provider), but Google, Microsoft, and most other major providers do.

Scopes limit what an access token can do. When you redirect the user, the scope parameter in the authorization request defines the permissions you are asking for. For GitHub, scope=read:user user:email gives you access to the user’s profile and email. For Google with OpenID Connect, scope=openid email profile gets you an ID token with basic identity claims.

Setting Up the Authorization Request

The authorization request is the first HTTP redirect your app makes. Getting the URL parameters right - especially the PKCE challenge - is the foundation of the entire flow.

Start by registering your OAuth app on GitHub. Go to Settings, then Developer settings, then OAuth Apps, then New OAuth App. Set the authorization callback URL to http://localhost:3000/callback for development. Note your client_id (public, safe to embed anywhere) and client_secret (used only server-side, never exposed to browsers).

Generate the PKCE values in Python:

import secrets
import hashlib
import base64

code_verifier = secrets.token_urlsafe(64)  # 86-character URL-safe string

code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()

The code_verifier is a cryptographically random string. The code_challenge is its SHA-256 hash, base64url-encoded with padding stripped. GitHub only accepts the S256 challenge method - plain challenges are not supported.

Construct the full authorization URL:

https://github.com/login/oauth/authorize?
  client_id=YOUR_CLIENT_ID
  &redirect_uri=http://localhost:3000/callback
  &scope=read:user user:email
  &state=RANDOM_STATE_VALUE
  &code_challenge=BASE64URL_SHA256_HASH
  &code_challenge_method=S256

The state parameter is a CSRF protection token. Generate it with secrets.token_urlsafe(32), store it in the user’s session, and verify it matches when the callback arrives. Without this, an attacker could forge authorization responses and trick your app into associating their GitHub account with a victim’s session.

Store the code_verifier in the server-side session. It must persist between the redirect and the callback. Use Flask’s built-in session (which is a signed cookie by default) or a server-side session store like Flask-Session backed by Redis. Never put the verifier in the URL, query parameters, or localStorage.

Then redirect the user: return redirect(authorization_url). They see GitHub’s login page, enter credentials, and consent to the requested scopes.

Handling the Callback and Exchanging the Code for Tokens

When GitHub redirects back to your callback URL, your backend receives two query parameters: code (the authorization code) and state (the CSRF token you sent earlier).

First, validate the state. Compare the state parameter from the callback with the value stored in the session. If they do not match, reject the request immediately - this is likely a CSRF attack.

if request.args.get("state") != session.get("oauth_state"):
    abort(403, "State mismatch - possible CSRF attack")

Next, exchange the authorization code for tokens. Send a POST request to GitHub’s token endpoint:

import requests

response = requests.post(
    "https://github.com/login/oauth/access_token",
    json={
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "code": request.args["code"],
        "redirect_uri": "http://localhost:3000/callback",
        "code_verifier": session["code_verifier"],
    },
    headers={"Accept": "application/json"},
)

token_data = response.json()
access_token = token_data["access_token"]

Why send both client_secret and code_verifier? For confidential clients (server-rendered apps that can keep secrets), using both provides defense in depth. The client_secret authenticates your application. The code_verifier proves the same browser session that initiated the flow is completing it. Public clients (SPAs, mobile apps) rely on PKCE alone since they cannot securely store a secret.

The token response from GitHub looks like this:

{
  "access_token": "gho_xxxxxxxxxxxxxxxxxxxx",
  "token_type": "bearer",
  "scope": "read:user,user:email"
}

GitHub’s OAuth tokens do not expire by default (though GitHub Apps issue tokens that expire after 8 hours). Other providers like Google return an expires_in field (typically 3600 seconds) and a refresh_token.

Now fetch the user’s profile:

user_response = requests.get(
    "https://api.github.com/user",
    headers={"Authorization": f"Bearer {access_token}"},
)
user = user_response.json()
# user contains: id, login, name, email, avatar_url, etc.

Use this data to create or update a user record in your database. The id field is the stable identifier - usernames can change, but IDs are permanent.

Handle errors gracefully. The callback might include ?error=access_denied&error_description=The+user+denied+your+request if the user declined consent. Check for the error parameter before trying to extract the code:

if "error" in request.args:
    return render_template("error.html",
        message=request.args.get("error_description", "Authorization failed"))

Token Storage, Refresh Tokens, and Session Management

Once you have tokens, storing them securely and managing their lifecycle is where most production OAuth vulnerabilities originate.

The recommended pattern for server-rendered apps is to store tokens in a server-side session. The browser receives only an opaque session cookie with HttpOnly, Secure, and SameSite=Lax flags set. The access token, refresh token, and user profile live on the server (in memory, Redis, or a database), never exposed to client-side JavaScript.

Never store tokens in localStorage or sessionStorage. Any cross-site scripting (XSS) vulnerability gives an attacker direct access to the token. HttpOnly cookies are immune to JavaScript access, which makes them the only safe client-side storage mechanism.

For providers that issue refresh tokens (Google, Microsoft, most OIDC providers), implement the refresh flow. When an API call returns a 401 Unauthorized, exchange the refresh token for a new access token:

refresh_response = requests.post(
    "https://oauth2.googleapis.com/token",
    data={
        "grant_type": "refresh_token",
        "refresh_token": session["refresh_token"],
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    },
)
new_tokens = refresh_response.json()
session["access_token"] = new_tokens["access_token"]
# Some providers rotate refresh tokens - store the new one if present
if "refresh_token" in new_tokens:
    session["refresh_token"] = new_tokens["refresh_token"]

A better approach than waiting for a 401 is proactive refresh. Store the expires_in value from the token response and refresh five minutes before expiry. This avoids failed API calls and the retry logic they require.

For logout, do three things: delete the server-side session, clear the session cookie, and optionally revoke the access token at the provider. GitHub’s revocation endpoint requires basic authentication with your client credentials:

DELETE https://api.github.com/applications/{CLIENT_ID}/token
Authorization: Basic base64(CLIENT_ID:CLIENT_SECRET)
Body: {"access_token": "the_token"}

If you need to store tokens in a database (for background jobs that run without a user session), encrypt them at rest. Python’s cryptography library provides Fernet symmetric encryption that handles AES-128 encryption, HMAC authentication, and timestamp validation in a single call:

from cryptography.fernet import Fernet

# Generate and store this key securely (environment variable or vault)
key = Fernet.generate_key()
f = Fernet(key)

# Encrypt before storing
encrypted_token = f.encrypt(access_token.encode())

# Decrypt when needed
decrypted_token = f.decrypt(encrypted_token).decode()

Rotate your Fernet keys periodically using MultiFernet, which lets you decrypt with old keys while encrypting with the current one.

A quick security checklist: use HTTPS everywhere (token exchange over plain HTTP is a critical vulnerability), keep access token lifetimes short, rotate refresh tokens on each use, validate token scopes before granting access to protected resources, and rate-limit your callback endpoint to prevent authorization code brute-forcing.

Building the Complete Flow in Flask

Here is the full working Flask application. Five routes cover the entire OAuth 2.0 + PKCE login flow against GitHub.

import secrets
import hashlib
import base64
import os

import requests
from flask import Flask, redirect, request, session, url_for, render_template_string
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]

CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
REDIRECT_URI = "http://localhost:3000/callback"

DASHBOARD_TEMPLATE = """
<h1>Dashboard</h1>
<p>Logged in as <strong>{{ user.login }}</strong></p>
<img src="{{ user.avatar_url }}" width="100">
<p>Name: {{ user.name }}</p>
<p>Email: {{ user.email }}</p>
<a href="/logout">Logout</a>
"""

@app.route("/")
def home():
    return '<h1>Home</h1><a href="/login">Sign in with GitHub</a>'

@app.route("/login")
def login():
    # Generate PKCE values
    code_verifier = secrets.token_urlsafe(64)
    code_challenge = base64.urlsafe_b64encode(
        hashlib.sha256(code_verifier.encode()).digest()
    ).rstrip(b"=").decode()
    state = secrets.token_urlsafe(32)

    # Store in session for callback verification
    session["code_verifier"] = code_verifier
    session["oauth_state"] = state

    auth_url = (
        f"https://github.com/login/oauth/authorize"
        f"?client_id={CLIENT_ID}"
        f"&redirect_uri={REDIRECT_URI}"
        f"&scope=read:user user:email"
        f"&state={state}"
        f"&code_challenge={code_challenge}"
        f"&code_challenge_method=S256"
    )
    return redirect(auth_url)

@app.route("/callback")
def callback():
    # Check for errors
    if "error" in request.args:
        return f"Error: {request.args.get('error_description', 'Unknown')}", 400

    # Validate state (CSRF protection)
    if request.args.get("state") != session.get("oauth_state"):
        return "State mismatch", 403

    # Exchange code for access token
    token_response = requests.post(
        "https://github.com/login/oauth/access_token",
        json={
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "code": request.args["code"],
            "redirect_uri": REDIRECT_URI,
            "code_verifier": session["code_verifier"],
        },
        headers={"Accept": "application/json"},
    )
    token_data = token_response.json()

    if "error" in token_data:
        return f"Token error: {token_data.get('error_description')}", 400

    session["access_token"] = token_data["access_token"]

    # Fetch user profile
    user_response = requests.get(
        "https://api.github.com/user",
        headers={"Authorization": f"Bearer {session['access_token']}"},
    )
    session["user"] = user_response.json()

    # Clean up PKCE values
    session.pop("code_verifier", None)
    session.pop("oauth_state", None)

    return redirect(url_for("dashboard"))

@app.route("/dashboard")
def dashboard():
    if "user" not in session:
        return redirect(url_for("login"))
    return render_template_string(DASHBOARD_TEMPLATE, user=session["user"])

@app.route("/logout")
def logout():
    # Optionally revoke the token at GitHub
    access_token = session.get("access_token")
    if access_token:
        requests.delete(
            f"https://api.github.com/applications/{CLIENT_ID}/token",
            auth=(CLIENT_ID, CLIENT_SECRET),
            json={"access_token": access_token},
        )
    session.clear()
    return redirect(url_for("home"))

if __name__ == "__main__":
    app.run(port=3000, debug=True)

The dependencies are minimal: flask, requests, and python-dotenv. No third-party auth libraries. Create a .env file with three values:

FLASK_SECRET_KEY=your-random-secret-key
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

Install and run:

pip install flask requests python-dotenv
python app.py

Open http://localhost:3000, click “Sign in with GitHub”, authorize the app, and you will land on the dashboard with your GitHub profile displayed.

Where to Go from Here

This implementation covers the core flow, but production apps need a few more pieces.

If you use Google or Microsoft as your provider instead of GitHub, the token response includes an id_token - a signed JWT containing identity claims. Verify it by fetching the provider’s public keys from their JWKS endpoint (found at /.well-known/openid-configuration), checking the signature with PyJWT , and validating the iss, aud, and exp claims. This gets you verified identity information without an extra API call to the userinfo endpoint.

Adding multiple providers (Google, Discord, etc.) alongside GitHub is mostly a matter of extracting the OAuth logic into a provider configuration dictionary. Each provider needs its own authorize_url, token_url, userinfo_url, client_id, client_secret, and scopes. The tricky part is normalizing user profiles across providers into a common format (id, email, name, avatar) since every provider returns slightly different JSON shapes.

For single-page applications without a backend, the entire flow happens in the browser. The SPA generates the PKCE verifier and challenge in JavaScript, redirects to the provider, and exchanges the code for tokens directly. Since there is no client_secret, PKCE alone provides the security binding. Store tokens in memory (not localStorage) and use refresh token rotation to maintain sessions. The Curity SPA best practices guide covers this pattern well.

Your callback endpoint should also be rate-limited to prevent authorization code brute-forcing. Flask-Limiter with a limit like 10/minute on the /callback route is an easy way to handle this. And if you store tokens in a database for background processing, encrypt them at rest using Fernet symmetric encryption from the cryptography library. Use MultiFernet for key rotation, and keep your encryption keys in environment variables or a secrets manager - never in the codebase.

The full flow implemented here comes out to roughly 120 lines of Python (excluding templates). Every line maps directly to something the OAuth specification requires. Understanding this foundation makes debugging any OAuth library much easier, because you know what is happening underneath the abstractions.