5. Build a safe authorization URL
The authorization URL is the handoff from your Python program to GitHub. It says which app is asking, where GitHub should send the browser back, what permission the app wants, which random state value identifies this flow, and which PKCE challenge binds the later token exchange to this request.
Choose the first scope deliberately
Scopes define what your app is allowed to do. Start with the smallest useful permission and expand only when a feature earns it.
| Scope | What it allows | When to use it |
|---|---|---|
read:user |
Read profile data. | The first safe demo: confirm who authorized the app. |
public_repo |
Access public repositories. | Use when the tool needs repository data but can stay public-only. |
repo |
Broad access to public and private repositories and related resources. | Use only when private repository features genuinely require it. |
The first script uses read:user. That is enough to complete the OAuth flow and call /user. Later, when the tool needs repositories, you will choose whether to add a repository scope. That moment should feel like a decision, not a default.
Be precise with the repository scopes. public_repo is narrower than repo, but it is not a pure read-only scope; it can allow write-like actions on public repository resources. repo is broader again and reaches private repositories and related resources. Use either one only after the feature needs it.
Build the authorization URL script
Now put the pieces from the previous page into a script. It will generate a fresh state value, create a private PKCE code_verifier, turn that verifier into a public code_challenge, and build the GitHub authorization URL.
Only the public pieces go into the URL: your app's client ID, callback URL, requested scope, state, and code_challenge. The script also prints the state and code_verifier because the next learning step runs as a separate file and needs those temporary values.
Save this as 1_authorization_url.py:
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode
from dotenv import load_dotenv
def create_code_challenge(verifier):
digest = hashlib.sha256(verifier.encode("ascii")).digest()
encoded = base64.urlsafe_b64encode(digest).decode("ascii")
return encoded.rstrip("=")
load_dotenv()
CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI")
if not CLIENT_ID or not REDIRECT_URI:
raise SystemExit("Missing GITHUB_CLIENT_ID or GITHUB_REDIRECT_URI in .env")
state = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
code_challenge = create_code_challenge(code_verifier)
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "read:user",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
auth_url = "https://github.com/login/oauth/authorize?" + urlencode(params)
print("Authorization URL:")
print(auth_url)
print()
print("Keep these values for the callback step:")
print(f"state={state}")
print(f"code_verifier={code_verifier}")
Run it:
python 1_authorization_url.py
Your values will differ on every run, because state and code_verifier are freshly random each time. The shape of a successful run looks like this:
Authorization URL:
https://github.com/login/oauth/authorize?client_id=Ov23liAbCdEfGh123456&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fcallback&scope=read%3Auser&state=zJqkT3vY8wA2mPnQ6cRb4eHd0fLgXs9iK1uW5oM7tEY&code_challenge=E9Mp4qXzKfTGc2sLvYwB7nRhUjA1oD5iC8eHbVxNkSY&code_challenge_method=S256
Keep these values for the callback step:
state=zJqkT3vY8wA2mPnQ6cRb4eHd0fLgXs9iK1uW5oM7tEY
code_verifier=N4xVb1pZ8sQk3aTw9dRf2gHj6mLc0yEn5uIoB7vKqX1MtCzS4hPaW8eGdJ2fYrULx0NgVi3kEw7BmSf6TzHqDs
Notice that the state in the URL and the state below it are the same value, while the code_verifier appears only below: the URL carries the public code_challenge derived from it, never the verifier itself.
Copy the printed state and code_verifier. You will paste them into the callback script on the next page.
Open the authorization URL in your browser so GitHub can show the app name and requested permission, but do not approve the request yet. Start the callback server on the next page first, 2_callback_exchange.py, then return to the browser and approve the request.
Treat the verifier as temporary secret material. We print it only because this chapter splits authorization URL generation from callback handling so you can inspect each step. In a real app, store state and code_verifier in memory, a server-side session, or another short-lived flow store instead of asking a user to copy them.
As the previous section stressed, generating state is only half the job; the callback step must compare the returned value against the one this script generated.
What this URL contains
client_id: identifies the GitHub OAuth App you registered.redirect_uri: tells GitHub where to send the browser after approval or cancellation.scope: starts narrow withread:user.state: lets your app reject callbacks it did not initiate.code_challenge: the public PKCE challenge GitHub records for this flow.code_challenge_method: tells GitHub the challenge was created with SHA-256.
After the user approves, GitHub redirects to your callback URL. The next page builds the local callback handler and performs the code exchange.