OAuth Social Login System Low-Level Design

OAuth Social Login System — Low-Level Design

An OAuth social login system allows users to authenticate via a third-party provider (Google, GitHub, Facebook) without creating a password. It must handle the OAuth flow, account linking, and the case where the same user connects via multiple providers. This design is asked at any company offering sign-in with Google/GitHub.

OAuth 2.0 Authorization Code Flow

Step 1: Redirect user to provider
  GET https://accounts.google.com/o/oauth2/auth
    ?client_id=YOUR_CLIENT_ID
    &redirect_uri=https://app.example.com/auth/google/callback
    &response_type=code
    &scope=openid email profile
    &state=CSRF_TOKEN  ← store in session before redirecting

Step 2: Provider redirects back with authorization code
  GET /auth/google/callback?code=AUTH_CODE&state=CSRF_TOKEN

Step 3: Exchange code for tokens (server-side, never expose client secret)
  POST https://oauth2.googleapis.com/token
    code=AUTH_CODE
    client_id=YOUR_CLIENT_ID
    client_secret=YOUR_SECRET
    redirect_uri=...
    grant_type=authorization_code
  Response: {access_token, id_token, refresh_token}

Step 4: Fetch user profile using access_token
  GET https://openidconnect.googleapis.com/v1/userinfo
  Authorization: Bearer ACCESS_TOKEN
  Response: {sub: "1234567", email: "user@gmail.com", name: "Alice", picture: "..."}

Core Data Model

User
  id              BIGSERIAL PK
  email           TEXT UNIQUE NOT NULL
  display_name    TEXT
  avatar_url      TEXT
  created_at      TIMESTAMPTZ

OAuthConnection
  id              BIGSERIAL PK
  user_id         BIGINT FK NOT NULL
  provider        TEXT NOT NULL      -- 'google', 'github', 'facebook'
  provider_user_id TEXT NOT NULL     -- provider's stable user identifier
  access_token    TEXT               -- encrypted at rest
  refresh_token   TEXT               -- encrypted at rest
  token_expires_at TIMESTAMPTZ
  created_at      TIMESTAMPTZ
  UNIQUE (provider, provider_user_id)  -- one connection per provider per user

Handling the Callback

def handle_oauth_callback(provider, code, state, session_state):
    # 1. Verify CSRF state token
    if state != session_state:
        raise SecurityError('State mismatch — possible CSRF attack')

    # 2. Exchange code for tokens
    tokens = exchange_code_for_tokens(provider, code)

    # 3. Fetch user profile from provider
    profile = fetch_user_profile(provider, tokens['access_token'])
    provider_user_id = profile['sub']  # Google uses 'sub'; GitHub uses 'id'
    email = profile.get('email')

    # 4. Find or create user
    user = find_or_create_user(provider, provider_user_id, email, profile, tokens)

    # 5. Create app session
    session_token = create_session(user.id)
    return session_token

def find_or_create_user(provider, provider_user_id, email, profile, tokens):
    # Case A: Existing OAuth connection — return the linked user
    existing_connection = db.get_by(OAuthConnection,
                                    provider=provider,
                                    provider_user_id=provider_user_id)
    if existing_connection:
        update_tokens(existing_connection.id, tokens)
        return db.get(User, existing_connection.user_id)

    # Case B: Email matches an existing user — link the new provider to their account
    if email:
        existing_user = db.get_by(User, email=email.lower())
        if existing_user:
            create_oauth_connection(existing_user.id, provider, provider_user_id, tokens)
            return existing_user

    # Case C: New user — create account and connection
    user = db.insert(User, {
        'email': email.lower() if email else None,
        'display_name': profile.get('name'),
        'avatar_url': profile.get('picture'),
    })
    create_oauth_connection(user.id, provider, provider_user_id, tokens)
    return user

Storing Tokens Securely

import cryptography.fernet

ENCRYPTION_KEY = os.environ['TOKEN_ENCRYPTION_KEY']  # 32-byte key
fernet = Fernet(ENCRYPTION_KEY)

def create_oauth_connection(user_id, provider, provider_user_id, tokens):
    encrypted_access = fernet.encrypt(tokens['access_token'].encode()).decode()
    encrypted_refresh = fernet.encrypt(tokens.get('refresh_token', '').encode()).decode()

    db.insert(OAuthConnection, {
        'user_id': user_id,
        'provider': provider,
        'provider_user_id': provider_user_id,
        'access_token': encrypted_access,
        'refresh_token': encrypted_refresh,
        'token_expires_at': now() + timedelta(seconds=tokens.get('expires_in', 3600)),
    })

Key Interview Points

  • State parameter prevents CSRF: Generate a random state value, store it in the server-side session before redirect, verify it matches on callback. Without this, an attacker can forge the callback URL.
  • Exchange code server-side only: The authorization code is exchanged for tokens using your client_secret. This must happen server-side. Never expose client_secret or access tokens to the browser.
  • Account linking by email can be dangerous: Linking a new provider to an existing account based on email alone is a security risk if the provider doesn’t verify email ownership. GitHub and some providers let users register unverified emails. Only auto-link on verified email.
  • Encrypt tokens at rest: Access and refresh tokens are credentials. If your database is compromised, unencrypted tokens give attackers access to users’ third-party accounts. Encrypt with AES-256 (Fernet) before storing.

OAuth and social login system design is discussed in Google system design interview questions.

OAuth and identity federation design is covered in Meta system design interview preparation.

OAuth and developer authentication design is discussed in Atlassian system design interview guide.

Scroll to Top