6. Catch the callback and exchange the code
After the user approves your app, GitHub redirects the browser back to your callback URL with a temporary authorization code. The callback script checks the returned state first, then sends the PKCE code_verifier during the token exchange.
Validate first, exchange second
This script finishes the flow that 1_authorization_url.py started. It listens on your local callback URL, reads the query parameters GitHub sends back, and refuses to exchange the authorization code unless the returned state matches the value you copied from the previous step.
Once the state check passes, the script sends the authorization code, client secret, redirect URI, and original code_verifier to GitHub's token endpoint. GitHub uses the verifier to check the PKCE challenge it recorded when you opened the authorization URL.
Exchange the code
Save this as 2_callback_exchange.py. For the learning version, use the state and code_verifier you copied from 1_authorization_url.py.
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import requests
from dotenv import load_dotenv
ENV_PATH = Path(".env")
load_dotenv()
CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI")
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
raise SystemExit("Missing one or more GitHub OAuth settings in .env")
EXPECTED_STATE = input("Paste the state from 1_authorization_url.py: ").strip()
CODE_VERIFIER = input("Paste the code_verifier from 1_authorization_url.py: ").strip()
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if "error" in params:
self.respond("Authorization cancelled or failed.")
print(f"GitHub returned error: {params['error'][0]}")
return
code = params.get("code", [None])[0]
returned_state = params.get("state", [None])[0]
if returned_state != EXPECTED_STATE:
self.respond("State mismatch. Token exchange aborted.")
print("State mismatch. Aborting before token exchange.")
return
if not code:
self.respond("No authorization code received.")
print("No authorization code received.")
return
token = exchange_code_for_token(code)
if token:
save_access_token(token)
self.respond("Authorized. You can close this tab.")
print("Access token saved to .env as GITHUB_ACCESS_TOKEN.")
print(f"Token preview: {mask_token(token)}")
else:
self.respond("Token exchange failed. Check the terminal.")
def respond(self, message):
body = f"<html><body><h2>{message}</h2></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def log_message(self, format, *args):
pass
def exchange_code_for_token(code):
response = requests.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"code": code,
"code_verifier": CODE_VERIFIER,
},
headers={"Accept": "application/json"},
timeout=10,
)
response.raise_for_status()
data = response.json()
if "error" in data:
print(f"Token error: {data['error']}")
print(data.get("error_description", "No description provided."))
return None
return data["access_token"]
def save_access_token(token):
token_line = f"GITHUB_ACCESS_TOKEN={token}"
lines = []
if ENV_PATH.exists():
lines = ENV_PATH.read_text(encoding="utf-8").splitlines()
for index, line in enumerate(lines):
if line.startswith("GITHUB_ACCESS_TOKEN="):
lines[index] = token_line
break
else:
lines.append(token_line)
ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
def mask_token(token):
if not token or len(token) < 12:
return "[hidden]"
return f"{token[:4]}...{token[-4:]}"
server = HTTPServer(("localhost", 8000), CallbackHandler)
print("Listening on http://localhost:8000/callback")
print("Now approve the GitHub authorization request in your browser.")
server.handle_request()
Run the script before approving the browser prompt:
python 2_callback_exchange.py
Paste the saved state and code_verifier when the script asks for them. When the terminal says it is listening on http://localhost:8000/callback, return to the browser and approve the GitHub authorization request.
A successful run looks like this. The first four lines appear before you touch the browser; the last two appear only after you approve the request, because the server is waiting for GitHub's callback in between:
Paste the state from 1_authorization_url.py: zJqkT3vY8wA2mPnQ6cRb4eHd0fLgXs9iK1uW5oM7tEY
Paste the code_verifier from 1_authorization_url.py: N4xVb1pZ8sQk3aTw9dRf2gHj6mLc0yEn5uIoB7vKqX1MtCzS4hPaW8eGdJ2fYrULx0NgVi3kEw7BmSf6TzHqDs
Listening on http://localhost:8000/callback
Now approve the GitHub authorization request in your browser.
Access token saved to .env as GITHUB_ACCESS_TOKEN.
Token preview: gho_...3kQz
The pasted values are the ones 1_authorization_url.py printed; carrying them across by hand is the point of the split. In the browser tab, GitHub's redirect lands on a plain "Authorized. You can close this tab." page served by your own script.
The server handles one callback and stops. It saves the full token to .env, but it does not print the full token. That is deliberate: tokens are credentials, and learning examples should not normalize leaking them into terminal history, screenshots, or logs.
What can go wrong here
- State mismatch: reject the callback before token exchange.
- Missing code: the user may have cancelled, or GitHub may have returned an error.
- Bad verifier: PKCE fails if the
code_verifierdoes not match the original challenge. - Expired or reused code: authorization codes are short-lived and single-use, so start a fresh flow instead of retrying the same code.
- Port conflict: if another process owns port
8000, stop it or use a different registered callback path and matching code.
Once the exchange works, the access token can call the API. The next page shows how to do that without pasting the token into source code.