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.