DEV Community

Cover image for I Thought OAuth Was Just Adding a Google Button. Turns Out It's a CSRF Problem Disguised as a Feature.
Ravi Gupta
Ravi Gupta

Posted on

I Thought OAuth Was Just Adding a Google Button. Turns Out It's a CSRF Problem Disguised as a Feature.

This is Part 2 of a 4-part series on building AuthShield - a production-ready standalone authentication microservice. This post covers the OAuth 2.0 implementation: Authorization Code Flow, PKCE, CSRF protection, and account linking.
Part 1 is here: Why I Stopped Writing Auth Code for Every Project and Built AuthShield


When I started implementing OAuth 2.0 in AuthShield, I thought it was going to be one of the easier parts.

Add a Google button. Redirect the user. Get their email back. Done.

I was wrong - not because OAuth is complicated, but because I did not understand what it actually is. I thought it was a login mechanism. It is not. OAuth 2.0 is an authorization framework, and almost every step in the flow exists to defend against a specific attack.

Once I understood that, everything clicked. But getting there took more time than I expected.


What OAuth 2.0 Actually Is

Most engineers encounter OAuth for the first time through social login. Click "Sign in with Google," get redirected, come back logged in. The experience is smooth and the implementation feels like it should match that smoothness.

But OAuth 2.0 was not designed for authentication. It was designed for authorization - specifically, for allowing a third-party application to access resources on a user's behalf without ever seeing the user's password.

The "Sign in with Google" use case is built on top of OAuth 2.0 using OpenID Connect, which adds an identity layer. When you implement Google login, you are using both, whether you realise it or not.

This distinction matters because it changes how you think about the flow. You are not just "getting the user." You are asking Google to vouch for the user's identity and hand you a verified email address. Every step in the flow is about ensuring that handoff cannot be tampered with.


The Authorization Code Flow

AuthShield uses the Authorization Code Flow, which is the most secure OAuth flow for server-side applications. Here is how it works:

  1. The user clicks "Sign in with Google"
  2. AuthShield redirects the user to Google's authorization server with a set of parameters — client ID, requested scopes, redirect URI, and two security parameters we will come back to
  3. The user logs into Google and grants permission
  4. Google redirects the user back to AuthShield's callback URL with a short-lived authorization code
  5. AuthShield exchanges that code for an access token by making a server-to-server request to Google — this is the key step, because the exchange happens on the backend, never in the browser
  6. AuthShield uses the access token to fetch the user's profile from Google
  7. AuthShield creates or finds the user in its own database and issues its own JWT

Step 5 is what makes this flow secure. The authorization code is short-lived and single-use. Even if someone intercepts the redirect in step 4, they cannot exchange the code - they do not have the client secret required for the server-to-server exchange.

The implicit flow, which used to be recommended for single-page applications, skipped step 5 and returned tokens directly in the URL fragment. That meant tokens were exposed in browser history, server logs, and referrer headers. It is deprecated now. Do not use it.


The State Parameter: The CSRF Problem I Almost Skipped

The first security parameter in the authorization request is the state parameter, and I almost did not implement it properly because it looked optional in the documentation.

It is not optional.

Here is the attack it prevents. Without a state parameter, an attacker can initiate an OAuth flow on your site and craft a malicious link to the callback URL. If they trick a victim into clicking that link — through a phishing email, a malicious website, anything - the victim's browser completes the OAuth callback and their account gets linked to the attacker's identity. The attacker now has access to the victim's account.

This is a Cross-Site Request Forgery attack applied to an OAuth flow. The state parameter prevents it by creating a binding between the authorization request and the callback.

Here is how AuthShield implements it:

import secrets
import json
from redis.asyncio import Redis

async def generate_oauth_state(redis: Redis) -> str:
    # Generate a cryptographically random state token
    state = secrets.token_urlsafe(32)

    # Store in Redis with a short TTL — 10 minutes is enough
    # After that, the state is invalid and the flow must restart
    await redis.setex(
        f"oauth_state:{state}",
        600,  # 10 minutes in seconds
        "1"
    )

    return state


async def verify_oauth_state(redis: Redis, state: str) -> bool:
    # Check if the state exists in Redis
    key = f"oauth_state:{state}"
    exists = await redis.exists(key)

    if not exists:
        return False

    # Delete immediately — state tokens are single-use
    # A replayed callback with the same state will fail
    await redis.delete(key)

    return True
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here. The state is stored in Redis, not the database. It is short-lived and single-use - once it is verified in the callback, it is deleted immediately. If an attacker somehow captures the state and tries to replay the callback, the token is already gone.

The callback handler verifies the state before doing anything else:

async def google_callback(
    code: str,
    state: str,
    redis: Redis = Depends(get_redis),
    db: AsyncSession = Depends(get_db)
):
    # First thing — verify the state
    # If this fails, reject the request entirely
    is_valid = await verify_oauth_state(redis, state)
    if not is_valid:
        raise HTTPException(
            status_code=400,
            detail="Invalid or expired state parameter"
        )

    # Only proceed if state is valid
    # Exchange code for token, fetch user info, etc.
    ...
Enter fullscreen mode Exit fullscreen mode

PKCE: The Part That Took Me the Longest

The second security parameter is PKCE - Proof Key for Code Exchange, pronounced "pixie." This is the part of the OAuth implementation that surprised me the most and took the longest to understand properly.

PKCE was originally designed for mobile and single-page applications that cannot securely store a client secret. But it is now recommended for all OAuth flows regardless of application type.

Here is the problem it solves. In the Authorization Code Flow, the authorization code is passed through the browser in a redirect. If an attacker can intercept that redirect - through a malicious app on the same device, a compromised browser extension, or a misconfigured redirect URI - they have the code. And if they also have the client secret, they can exchange it for tokens.

PKCE removes the client secret from the equation for the code exchange. Instead, the client proves it initiated the original request by solving a cryptographic challenge.

Here is how it works:

import hashlib
import base64
import secrets

def generate_pkce_pair() -> tuple[str, str]:
    # Step 1: Generate a random code verifier
    # This is a long random string that only the client knows
    code_verifier = secrets.token_urlsafe(64)

    # Step 2: Hash the verifier to create the code challenge
    # SHA-256 hash, then base64url encode it
    digest = hashlib.sha256(code_verifier.encode()).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

    return code_verifier, code_challenge
Enter fullscreen mode Exit fullscreen mode

The flow with PKCE works like this:

  1. AuthShield generates the code verifier and code challenge before redirecting to Google
  2. The code challenge is sent to Google in the authorization request
  3. The code verifier is stored temporarily - in AuthShield's case, in Redis alongside the state token
  4. When Google redirects back with the authorization code, AuthShield sends both the code and the original code verifier to exchange for tokens
  5. Google hashes the verifier, compares it to the challenge it stored from step 2, and only issues tokens if they match

An attacker who intercepts the authorization code cannot exchange it because they do not have the code verifier. It never left the server.

async def initiate_google_oauth(redis: Redis) -> str:
    # Generate state and PKCE pair
    state = await generate_oauth_state(redis)
    code_verifier, code_challenge = generate_pkce_pair()

    # Store the code verifier in Redis linked to the state
    # So we can retrieve it in the callback
    await redis.setex(
        f"pkce_verifier:{state}",
        600,
        code_verifier
    )

    # Build the Google authorization URL
    params = {
        "client_id": settings.GOOGLE_CLIENT_ID,
        "redirect_uri": settings.GOOGLE_REDIRECT_URI,
        "response_type": "code",
        "scope": "openid email profile",
        "state": state,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
    }

    base_url = "https://accounts.google.com/o/oauth2/v2/auth"
    return f"{base_url}?{'&'.join(f'{k}={v}' for k, v in params.items())}"


async def exchange_code_for_token(
    code: str,
    state: str,
    redis: Redis
) -> dict:
    # Retrieve the code verifier stored during initiation
    code_verifier = await redis.get(f"pkce_verifier:{state}")
    if not code_verifier:
        raise HTTPException(status_code=400, detail="PKCE verifier not found")

    # Clean up
    await redis.delete(f"pkce_verifier:{state}")

    # Exchange code for token — include the verifier, not the challenge
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://oauth2.googleapis.com/token",
            data={
                "client_id": settings.GOOGLE_CLIENT_ID,
                "client_secret": settings.GOOGLE_CLIENT_SECRET,
                "code": code,
                "redirect_uri": settings.GOOGLE_REDIRECT_URI,
                "grant_type": "authorization_code",
                "code_verifier": code_verifier,  # This is what proves we initiated the request
            }
        )

    return response.json()
Enter fullscreen mode Exit fullscreen mode

The reason this took me time to understand was not the code itself - it is not complicated once you see it. It was understanding why each step exists and what happens if you skip it. The docs describe PKCE as an enhancement. In practice, for any public-facing OAuth flow, it is a requirement.


Account Linking: The Edge Case Nobody Thinks About

Once the OAuth flow works, there is an edge case that is easy to miss: what happens when a user who already has an email and password account tries to sign in with Google using the same email?

There are two options. Reject them with an error - "this email is already registered, please log in with your password." Or link the accounts - recognise that it is the same person and give them access.

Rejecting them is the wrong call. It creates a confusing user experience and forces the user to remember which method they used to sign up. Linking them is the right call, but it requires careful handling.

async def find_or_create_oauth_user(
    email: str,
    full_name: str,
    oauth_provider: str,
    oauth_id: str,
    avatar_url: str,
    db: AsyncSession
) -> User:
    # First, check if a user with this OAuth provider ID already exists
    # This handles returning OAuth users
    existing_oauth_user = await user_repo.get_by_oauth(
        db, provider=oauth_provider, oauth_id=oauth_id
    )
    if existing_oauth_user:
        return existing_oauth_user

    # Check if a user with this email exists (registered with password)
    existing_email_user = await user_repo.get_by_email(db, email=email)

    if existing_email_user:
        # Link the OAuth account to the existing user
        # They can now sign in with either method
        existing_email_user.oauth_provider = oauth_provider
        existing_email_user.oauth_id = oauth_id
        existing_email_user.avatar_url = avatar_url
        # Mark as verified — the OAuth provider already verified the email
        existing_email_user.is_verified = True
        await db.commit()
        return existing_email_user

    # New user — create them
    # No password hash needed — they authenticate via OAuth
    # Mark as verified immediately — OAuth provider verified the email
    new_user = User(
        email=email,
        full_name=full_name,
        password_hash=None,  # Nullable — OAuth users have no password
        oauth_provider=oauth_provider,
        oauth_id=oauth_id,
        avatar_url=avatar_url,
        is_verified=True,  # Google already verified it
        is_active=True,
    )
    db.add(new_user)
    await db.commit()
    return new_user
Enter fullscreen mode Exit fullscreen mode

Two things worth noting in this implementation.

First, the lookup order matters. We check by OAuth provider ID first, then by email. This handles the case where a user has already linked their OAuth account - we find them immediately without touching the email lookup.

Second, OAuth users are marked as verified immediately. Google has already verified the email address. Requiring them to go through email verification again would be both redundant and confusing. The password_hash field in the database is nullable specifically to accommodate users who authenticate only through OAuth and have no password.


What OAuth Actually Is

After implementing all of this, my understanding of OAuth shifted.

It is not a login feature. It is a set of security boundaries, each one defending against a specific attack. The state parameter defends against CSRF. PKCE defends against authorization code interception. The server-side code exchange defends against token exposure in the browser. Account linking defends against a fragmented user experience that pushes users toward insecure workarounds.

When I thought of it as "adding a Google button," I was looking at the 1% of the implementation that is visible. The other 99% is the security reasoning underneath it.

Understanding that reasoning is the difference between OAuth that works and OAuth that holds up.


What Is Next

Next week: JWTs, logout, and the conflict between stateless tokens and immediate revocation.

I thought JWTs were stateless and that was their whole point. Then I tried to implement logout. The solution - Redis blacklisting, token families, and reuse detection - turned out to be the most interesting engineering problem in the entire project.

AuthShield on GitHub: AuthShield-Repository

Always learning, always observing.

Top comments (2)

Collapse
 
sneh1117 profile image
sneh1117

You are so right !! it took me forever to get rid of the csrf warning because i kept forgetting to update my uris in my google cloud console

Collapse
 
ravigupta97 profile image
Ravi Gupta

Haha yes, the redirect URI mismatch is one of those errors that looks cryptic the first time but the moment you figure it out you never forget it. Google's error message does not exactly make it obvious what is wrong. Glad it resonated, that kind of debugging experience is exactly why I wanted to write this out properly rather than just sharing the code.