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.

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”What is the state parameter in OAuth and why is it mandatory?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The state parameter is a CSRF protection token. Before redirecting to the provider: generate a random string, store it in the user’s server-side session, include it as the state parameter in the authorization URL. When the provider redirects back to your callback URL with ?code=…&state=…: verify that the returned state matches the one stored in the session. Without this check, an attacker can craft a callback URL with their own authorization code and trick your server into linking the attacker’s social account to the victim’s account (login CSRF / account hijacking). The state parameter is mandatory in OAuth 2.0 for security.”}},{“@type”:”Question”,”name”:”How do you handle the case where a user signs in with Google but already has a password account?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Match by verified email address: if the Google profile email matches an existing User.email in your database, link the new OAuthConnection to that existing user (rather than creating a duplicate account). Set the condition: only auto-link if the provider has verified the email (Google always verifies; some providers don’t). Show the user a message: "We’ve linked your Google account to your existing account." If you don’t auto-link, the user ends up with two accounts with separate data, which is a major UX problem and hard to merge later.”}},{“@type”:”Question”,”name”:”Why should OAuth tokens be encrypted before storing in the database?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Access and refresh tokens are credentials — they grant the ability to act on behalf of the user in the provider’s system (read emails, post to social media). Storing them in plaintext means a database breach gives attackers access to all users’ connected accounts on Google, GitHub, or Facebook. Encrypt with AES-256 (using a key from a secrets manager like AWS KMS or HashiCorp Vault) before inserting. Decrypt only when the token is needed (e.g., to make an API call on the user’s behalf). Key rotation: support encrypting with a new key while old records are re-encrypted in a background job.”}},{“@type”:”Question”,”name”:”How do you refresh an expired OAuth access token?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Access tokens expire (typically in 1 hour). Use the refresh_token (if provided) to get a new access_token: POST to the provider’s token endpoint with grant_type=refresh_token. On receiving a new access_token (and possibly a new refresh_token): update the OAuthConnection record with the new values and the new token_expires_at. Implement token refresh lazily: check token_expires_at before making an API call; if expired or within 5 minutes of expiry, refresh first. If the refresh_token is also expired or revoked (user revoked app access), redirect the user to re-authorize.”}},{“@type”:”Question”,”name”:”How do you allow a user to connect multiple social providers to one account?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The OAuthConnection table stores one row per (user_id, provider) pair with UNIQUE (provider, provider_user_id). A single user can have connections to Google, GitHub, and Facebook simultaneously. On the settings page: show connected providers and allow adding or removing connections. Removing: DELETE FROM OAuthConnection WHERE user_id=%(uid)s AND provider=%(provider)s. Safety check before removing: ensure the user has at least one remaining login method (either another OAuth connection or a password set), otherwise they would be locked out. Never remove the last login method.”}}]}

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