Skip to content
Hi! I am Gab

All notes

Oct 2026

Inside "Sign in with Google": OAuth 2.0, PKCE, and OpenID Connect from the Backend

The "Sign in with Google" button hides two protocols, three secrets, and a signed token your backend must verify before it trusts a single claim. I first met this flow while reviewing the social-login plugin of a production e-commerce backend built on Django (Mugna Internship).

Two protocols, two questions

People say "OAuth" for the whole thing, but two separate standards are involved:

OAuth 2.0OpenID Connect (OIDC)
Question it answersCan this app access the user's resources?Who is this user?
ConcernAuthorizationAuthentication
Main artifactAccess token (opaque to the client)ID token (a signed JWT the client reads)
Added byRFC 6749A layer on top, triggered by the openid scope

An access token is meant for an API, so your app shouldn't try to read identity out of it. The ID token is meant for your app, and it's the thing you validate to log someone in.

The variant covered here is the Authorization Code flow with PKCE. The newer security guidance (RFC 9700) recommends PKCE for every client, including server-side apps that also hold a client secret.

The flow at a glance

Inside "Sign in with Google": OAuth 2.0, PKCE, and OpenID Connect from the Backend: cover image

The browser only ever carries public or one-time values: state, nonce, the PKCE challenge, and a short-lived code. The client secret, the PKCE verifier, and the tokens travel only on the back channel between your server and the provider.

Step 1: Build the authorization request

Before redirecting anyone, the backend creates three single-use secrets and saves them server-side, tied to this login attempt.

SecretDefends againstWhere it travels
stateLogin CSRF: an attacker sending their own authorization response to your callbackOut in the URL, back in the callback
nonceID token replayOut in the URL, back inside the signed ID token
code_verifierA stolen authorization code being redeemedNever in the URL. Only its SHA-256 hash goes out. The verifier itself goes straight to the token endpoint in Step 2

PKCE (RFC 7636) works like this. The verifier is a high-entropy random string of 43 to 128 characters. The challenge is BASE64URL(SHA256(verifier)) with the padding stripped, sent with code_challenge_method=S256. Never use the plain method.

import base64
import hashlib
import secrets
from urllib.parse import urlencode

AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"

def b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def start_login(request):
    state = secrets.token_urlsafe(32)
    nonce = secrets.token_urlsafe(32)
    code_verifier = secrets.token_urlsafe(64)  # 86 chars, within 43-128
    code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())

    # Server-side session: the browser only holds an opaque session cookie
    request.session["oidc"] = {
        "state": state,
        "nonce": nonce,
        "code_verifier": code_verifier,
    }

    params = {
        "response_type": "code",
        "client_id": settings.OIDC_CLIENT_ID,
        "redirect_uri": settings.OIDC_REDIRECT_URI,
        "scope": "openid email profile",
        "state": state,
        "nonce": nonce,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
    }
    return redirect(f"{AUTHORIZE_URL}?{urlencode(params)}")

Two details matter here:

  • redirect_uri must exactly match a URI registered in the provider console, character for character, including any trailing slash. Providers reject anything else, which is what stops an attacker from redirecting codes to their own server.
  • The openid scope is the switch that turns on OIDC. Without it, you get plain OAuth and no ID token.

Step 2: Handle the callback and exchange the code

The provider redirects the browser back with ?code=...&state=..., or with ?error=... if the user cancelled. The backend checks state first, then trades the code for tokens over a direct server-to-server call.

import requests

TOKEN_URL = "https://oauth2.googleapis.com/token"

class LoginError(Exception):
    pass

def callback(request):
    if "error" in request.GET:
        raise LoginError(request.GET["error"])

    # Pop, don't get: these secrets are single-use
    saved = request.session.pop("oidc", None)
    if saved is None:
        raise LoginError("No login in progress (session expired or replayed)")

    returned_state = request.GET.get("state", "")
    if not secrets.compare_digest(returned_state, saved["state"]):
        raise LoginError("State mismatch")

    code = request.GET.get("code")
    if not code:
        raise LoginError("Missing authorization code")

    resp = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": settings.OIDC_REDIRECT_URI,  # same value as Step 1
            "client_id": settings.OIDC_CLIENT_ID,
            "client_secret": settings.OIDC_CLIENT_SECRET,
            "code_verifier": saved["code_verifier"],
        },
        timeout=10,
    )
    resp.raise_for_status()
    tokens = resp.json()  # access_token, id_token, expires_in, maybe refresh_token

    claims = verify_id_token(tokens["id_token"], expected_nonce=saved["nonce"])
    return log_in_user(request, claims)

What the provider does on its side during this exchange:

  1. It checks that the code is real, unexpired (usually minutes), and unused. Codes are single-use.
  2. It checks that client_id and client_secret match the client the code was issued to.
  3. It checks that redirect_uri equals the one from Step 1.
  4. It computes BASE64URL(SHA256(code_verifier)) and compares the result with the stored code_challenge. A thief who has the code but not the verifier fails here.

A few notes on the code itself:

  • secrets.compare_digest compares in constant time, so the check doesn't leak how many characters matched.
  • session.pop makes the state, nonce, and verifier unusable a second time, even if the callback URL is replayed.
  • The token request happens server to server. Tokens never pass through the browser's address bar or history.

Step 3: Verify the ID token before trusting it

The ID token is a JWT: three base64url parts, header.payload.signature. Anyone can decode the payload. Trust comes only from verifying the signature and then every relevant claim.

The signature and JWKS

The provider signs ID tokens with a private key (RS256 for Google) and publishes the matching public keys as a JWKS (JSON Web Key Set). Google's is at https://www.googleapis.com/oauth2/v3/certs. The provider's discovery document at /.well-known/openid-configuration lists it as jwks_uri.

The JWT header carries a kid (key ID) that tells you which key in the set signed it. Keys rotate, so fetch the JWKS, cache it, and refetch when you see a kid you don't recognize.

The claims, and what to check

ClaimMeaningCheck
issIssuerExactly the provider's issuer. Google uses https://accounts.google.com or accounts.google.com
audAudienceContains your client_id. A token minted for another app must fail here
azpAuthorized partyIf aud has several values, must equal your client_id
expExpiryIn the future, allowing a small clock skew
iatIssued atPresent and reasonable
nonceReplay protectionEquals the nonce saved in Step 1
subSubjectThe stable user ID. Use it as the account key, not email
email_verifiedEmail ownershipMust be true before you link to an existing account by email

In Python, with PyJWT

import jwt  # PyJWT

JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
GOOGLE_ISSUERS = {"https://accounts.google.com", "accounts.google.com"}
_jwks_client = jwt.PyJWKClient(JWKS_URL)  # caches keys, picks one by kid

def verify_id_token(id_token: str, expected_nonce: str) -> dict:
    signing_key = _jwks_client.get_signing_key_from_jwt(id_token)

    claims = jwt.decode(
        id_token,
        signing_key.key,
        algorithms=["RS256"],             # pin it: never trust the header's alg
        audience=settings.OIDC_CLIENT_ID,  # checks aud
        leeway=60,                         # seconds of clock skew for exp/iat
        options={"require": ["iss", "sub", "aud", "exp", "iat"]},
    )

    if claims["iss"] not in GOOGLE_ISSUERS:
        raise LoginError("Unexpected issuer")
    if isinstance(claims["aud"], list) and claims.get("azp") != settings.OIDC_CLIENT_ID:
        raise LoginError("azp mismatch")
    if not secrets.compare_digest(claims.get("nonce", ""), expected_nonce):
        raise LoginError("Nonce mismatch")

    return claims

Why algorithms=["RS256"] matters

A JWT's header names its own algorithm. If the verifier lets the token choose, an attacker can try alg: none, or switch to HS256 and sign the token with your public key as if it were a shared secret. This is the classic algorithm-confusion attack. Pinning the expected algorithm closes that door.

Then, and only then, log the user in

Look up the account by (issuer, sub). Create one if it doesn't exist, then start your own session. Everything about who this person is now rests on checks you made, not on what the token says about itself.

How a production backend wires it

The code above is the textbook version. Reading a real implementation, the OpenID Connect plugin in the open-source Saleor e-commerce platform, showed me three design decisions the tutorials skip.

1. Everything provider-specific lives in config

The plugin holds one typed configuration object per provider:

@dataclass
class OpenIDConnectConfig:
    client_id: str
    client_secret: str
    enable_refresh_token: bool
    json_web_key_set_url: str   # JWKS, for signature checks
    authorization_url: str      # Step 1 redirect target
    token_url: str              # Step 2 code exchange
    user_info_url: str
    logout_url: str
    audience: str
    use_scope_permissions: bool
    staff_user_domains: str     # which email domains become staff
    default_group_name: str     # permission group for new staff

The flow code never hardcodes Google. Switching or adding a provider is a configuration change, not a code change.

2. The state value doubles as the session key

The platform is a headless API: a separate storefront calls it over GraphQL, and it doesn't normally use cookie sessions. That raises a question: where do the Step 1 secrets live between the redirect and the callback?

The answer is neat. At login start, the backend creates a server-side session explicitly, and uses the random state value as that session's key. The session stores the storefront's redirect URL and the flow's secrets. When the callback arrives, the backend reopens the session using the state it received:

# start
session = SessionStore()
session._session_key = state      # state IS the lookup key
session.save(must_create=True)

# callback
session = SessionStore(session_key=request_data["state"])

If state is missing or forged, there's simply no session to reopen, so the CSRF check and the session lookup become the same step. The storefront's redirect URL is also checked against an allowlist before the flow starts.

3. Provider tokens never leave the backend

After the code exchange and ID token checks, the platform doesn't give Google's tokens to the storefront. It finds or creates the user, then issues its own JWT access token and refresh token, with a CSRF token bound to the refresh token.

That keeps a clean trust boundary:

  • The rest of the system only ever validates tokens it issued itself.
  • Google's tokens, scopes, and lifetimes don't leak into the app's own authorization rules.
  • Logging out or revoking access is under the app's control.

The errors are also explicit. A missing state, a wrong redirect_uri, or missing fields each raise a validation error with a specific code, so a failed login says why it failed.

Pitfalls that quietly break it

MistakeWhat happensFix
No state, or not checking itLogin CSRF: a victim gets logged into the attacker's accountRandom per attempt, stored server-side, compared in constant time, single-use
redirect_uri differs between Step 1 and Step 2redirect_uri_mismatch from the providerOne setting, used in both places, matching the console exactly
plain PKCE, or no PKCEAn intercepted code can be redeemedS256 always
Reading the ID token without verifying itAnyone can forge identity claimsVerify the signature against JWKS before reading any claim
Letting the token choose algAlgorithm-confusion attackPin algorithms=["RS256"]
Skipping the aud checkA token issued to some other app logs users into yoursaud must contain your client_id
Keying accounts on emailEmails change and can be reassigned, leading to account takeoverKey on (iss, sub). Link by email only when email_verified is true
Hardcoding one JWKS keyLogins break when the provider rotates keysFetch by kid, cache, refetch on unknown kid
Session lost between redirect and callback"No login in progress" errors, often from cookie SameSite settingsUse SameSite=Lax cookies, or a state-keyed server session as above
Passing provider tokens to the frontendYour app's security depends on a third party's token lifetimesIssue your own session or JWTs after login

What I took away

Each parameter in this flow exists because someone found an attack without it. state answers CSRF, PKCE answers code interception, nonce answers replay, and aud answers token substitution. Once I saw them as answers to specific attacks, the flow stopped feeling like boilerplate and became something I could reason about and debug.

The bigger lesson came from reading production code. Tutorials teach the happy path. Real systems show you where secrets live between requests, which tokens cross which boundary, and how a failure explains itself.

Further reading