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:

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:

Terminal
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:

Terminal (example output)
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 with read: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.