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.0 | OpenID Connect (OIDC) | |
|---|---|---|
| Question it answers | Can this app access the user's resources? | Who is this user? |
| Concern | Authorization | Authentication |
| Main artifact | Access token (opaque to the client) | ID token (a signed JWT the client reads) |
| Added by | RFC 6749 | A 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

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.
| Secret | Defends against | Where it travels |
|---|---|---|
state | Login CSRF: an attacker sending their own authorization response to your callback | Out in the URL, back in the callback |
nonce | ID token replay | Out in the URL, back inside the signed ID token |
code_verifier | A stolen authorization code being redeemed | Never 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_urimust 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
openidscope 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:
- It checks that the code is real, unexpired (usually minutes), and unused. Codes are single-use.
- It checks that
client_idandclient_secretmatch the client the code was issued to. - It checks that
redirect_uriequals the one from Step 1. - It computes
BASE64URL(SHA256(code_verifier))and compares the result with the storedcode_challenge. A thief who has the code but not the verifier fails here.
A few notes on the code itself:
secrets.compare_digestcompares in constant time, so the check doesn't leak how many characters matched.session.popmakes 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
| Claim | Meaning | Check |
|---|---|---|
iss | Issuer | Exactly the provider's issuer. Google uses https://accounts.google.com or accounts.google.com |
aud | Audience | Contains your client_id. A token minted for another app must fail here |
azp | Authorized party | If aud has several values, must equal your client_id |
exp | Expiry | In the future, allowing a small clock skew |
iat | Issued at | Present and reasonable |
nonce | Replay protection | Equals the nonce saved in Step 1 |
sub | Subject | The stable user ID. Use it as the account key, not email |
email_verified | Email ownership | Must 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
| Mistake | What happens | Fix |
|---|---|---|
No state, or not checking it | Login CSRF: a victim gets logged into the attacker's account | Random per attempt, stored server-side, compared in constant time, single-use |
redirect_uri differs between Step 1 and Step 2 | redirect_uri_mismatch from the provider | One setting, used in both places, matching the console exactly |
plain PKCE, or no PKCE | An intercepted code can be redeemed | S256 always |
| Reading the ID token without verifying it | Anyone can forge identity claims | Verify the signature against JWKS before reading any claim |
Letting the token choose alg | Algorithm-confusion attack | Pin algorithms=["RS256"] |
Skipping the aud check | A token issued to some other app logs users into yours | aud must contain your client_id |
Keying accounts on email | Emails change and can be reassigned, leading to account takeover | Key on (iss, sub). Link by email only when email_verified is true |
| Hardcoding one JWKS key | Logins break when the provider rotates keys | Fetch by kid, cache, refetch on unknown kid |
| Session lost between redirect and callback | "No login in progress" errors, often from cookie SameSite settings | Use SameSite=Lax cookies, or a state-keyed server session as above |
| Passing provider tokens to the frontend | Your app's security depends on a third party's token lifetimes | Issue 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.