A payment platform that asks developers to create yet another username and password is starting on the wrong foot. Developers already have accounts with Google, GitHub, Microsoft, and Apple. They expect to click one button and be inside.
We implemented four OAuth providers for 0fee.dev. Three follow the standard redirect flow. One -- Apple -- uses a popup flow via its JavaScript SDK and requires special handling that cost us more time than the other three combined.
The Architecture
All four OAuth providers share the same fundamental pattern:
- User clicks "Sign in with X"
- User is redirected to the provider's consent screen
- Provider redirects back with an authorization code
- Our backend exchanges the code for an access token
- We fetch the user's profile (email, name, avatar)
- We create or link the user account
- We issue a 0fee JWT token
The differences are in the details. Each provider has its own consent URL format, token exchange endpoint, profile endpoint, and quirks.
python# Database columns for OAuth identity linking
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
email = Column(String, unique=True, nullable=True)
name = Column(String, nullable=True)
# OAuth provider IDs
google_id = Column(String, unique=True, nullable=True)
github_id = Column(String, unique=True, nullable=True)
microsoft_id = Column(String, unique=True, nullable=True)
apple_id = Column(String, unique=True, nullable=True)
# Auth metadata
password_hash = Column(String, nullable=True) # Null for OAuth-only users
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())Each OAuth provider gets its own column: google_id, github_id, microsoft_id, apple_id. A single user can link multiple providers. If someone signs in with Google and later signs in with GitHub using the same email, we link both provider IDs to the same account.
Google OAuth: The Straightforward One
Google OAuth 2.0 is the most well-documented and predictable OAuth implementation. The flow works exactly as the documentation describes, with no surprises:
python# routes/auth/google.py
from httpx import AsyncClient
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
@router.get("/auth/google")
async def google_login():
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": f"{BASE_URL}/auth/google/callback",
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
"prompt": "consent",
}
url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
return RedirectResponse(url)
@router.get("/auth/google/callback") async def google_callback(code: str): async with AsyncClient() as client: # Exchange code for tokens token_response = await client.post(GOOGLE_TOKEN_URL, data={ "code": code, "client_id": GOOGLE_CLIENT_ID, "client_secret": GOOGLE_CLIENT_SECRET, "redirect_uri": f"{BASE_URL}/auth/google/callback", "grant_type": "authorization_code", }) tokens = token_response.json() BLANK # Fetch user profile user_response = await client.get( GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {tokens['access_token']}"} ) profile = user_response.json() BLANK # Create or link user user = await find_or_create_user( provider="google", provider_id=profile["id"], email=profile.get("email"), name=profile.get("name"), ) BLANK # Issue JWT and redirect to dashboard token = create_access_token(user.id) return RedirectResponse(f"/get-started?token={token}") ```
Google provides the email directly in the userinfo response, and the openid scope ensures we get a stable user ID.
GitHub OAuth: The Developer-Friendly One
GitHub's OAuth is almost identical to Google's, with one catch: the email endpoint is separate from the user profile endpoint. GitHub users can have private emails, so you need an extra API call:
python# routes/auth/github.py
GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_USER_URL = "https://api.github.com/user"
GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
@router.get("/auth/github/callback")
async def github_callback(code: str):
async with AsyncClient() as client:
# Exchange code for token
token_response = await client.post(
GITHUB_TOKEN_URL,
data={
"code": code,
"client_id": GITHUB_CLIENT_ID,
"client_secret": GITHUB_CLIENT_SECRET,
},
headers={"Accept": "application/json"}
)
access_token = token_response.json()["access_token"]
# Fetch user profile
headers = {"Authorization": f"Bearer {access_token}"}
user_response = await client.get(GITHUB_USER_URL, headers=headers)
profile = user_response.json()
# Fetch primary email (may be private)
email_response = await client.get(GITHUB_EMAILS_URL, headers=headers)
emails = email_response.json()
primary_email = next(
(e["email"] for e in emails if e["primary"] and e["verified"]),
None
)
user = await find_or_create_user(
provider="github",
provider_id=str(profile["id"]),
email=primary_email,
name=profile.get("name") or profile.get("login"),
)
token = create_access_token(user.id)
return RedirectResponse(f"/get-started?token={token}")Notice the fallback for the name field: profile.get("name") or profile.get("login"). Many GitHub users do not set a display name, so we fall back to their username.
Microsoft OAuth: Azure AD Multi-Tenant
Microsoft OAuth uses Azure Active Directory, and the multi-tenant configuration was the key decision. With a single-tenant app, only users from one Azure AD organization can sign in. With multi-tenant, any Microsoft account works -- personal, work, or school:
python# routes/auth/microsoft.py
# Multi-tenant: "common" allows any Microsoft account
MICROSOFT_AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
MICROSOFT_GRAPH_URL = "https://graph.microsoft.com/v1.0/me"
@router.get("/auth/microsoft")
async def microsoft_login():
params = {
"client_id": MICROSOFT_CLIENT_ID,
"redirect_uri": f"{BASE_URL}/auth/microsoft/callback",
"response_type": "code",
"scope": "openid email profile User.Read",
"response_mode": "query",
}
url = f"{MICROSOFT_AUTH_URL}?{urlencode(params)}"
return RedirectResponse(url)
@router.get("/auth/microsoft/callback") async def microsoft_callback(code: str): async with AsyncClient() as client: token_response = await client.post(MICROSOFT_TOKEN_URL, data={ "code": code, "client_id": MICROSOFT_CLIENT_ID, "client_secret": MICROSOFT_CLIENT_SECRET, "redirect_uri": f"{BASE_URL}/auth/microsoft/callback", "grant_type": "authorization_code", "scope": "openid email profile User.Read", }) tokens = token_response.json() BLANK user_response = await client.get( MICROSOFT_GRAPH_URL, headers={"Authorization": f"Bearer {tokens['access_token']}"} ) profile = user_response.json() BLANK user = await find_or_create_user( provider="microsoft", provider_id=profile["id"], email=profile.get("mail") or profile.get("userPrincipalName"), name=profile.get("displayName"), ) BLANK token = create_access_token(user.id) return RedirectResponse(f"/get-started?token={token}") ```
The User.Read scope is essential for Microsoft Graph API access. Without it, the /me endpoint returns a 403. Also note the email fallback: profile.get("mail") or profile.get("userPrincipalName"). Personal Microsoft accounts have mail, but organizational accounts sometimes only have userPrincipalName.
The /common tenant in the URL is what makes this multi-tenant. Using /consumers would restrict to personal accounts, /organizations would restrict to work/school accounts.
Apple OAuth: The Outlier
Apple Sign In is different from the other three providers in fundamental ways:
- Popup flow instead of redirect flow. Apple's JS SDK opens a popup window for authentication rather than redirecting the current page.
- POST callback instead of GET. Apple sends the authorization code via a POST request to the callback URL, not a GET redirect with query parameters.
- Identity token is a JWT. Instead of a separate profile endpoint, Apple embeds user information in a JWT identity token.
- User info is sent only on first login. Apple sends the user's name and email in the POST body only the first time they authorize your app. On subsequent logins, you only get the identity token.
html<!-- Frontend: Apple Sign In button with JS SDK -->
<script
type="text/javascript"
src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"
></script>
<script>
AppleID.auth.init({
clientId: '${APPLE_CLIENT_ID}',
scope: 'name email',
redirectURI: '${BASE_URL}/auth/apple/callback',
usePopup: true,
});
document.getElementById('apple-sign-in').addEventListener('click', async () => {
try {
const response = await AppleID.auth.signIn();
// Send to our backend
const result = await fetch('/api/auth/apple/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: response.authorization.code,
id_token: response.authorization.id_token,
user: response.user, // Only present on first login
}),
});
const data = await result.json();
window.location.href = `/get-started?token=${data.token}`;
} catch (error) {
console.error('Apple Sign In failed:', error);
}
});
</script>The backend handling for Apple is more involved because we need to validate the JWT identity token:
python# routes/auth/apple.py
from jose import jwt as jose_jwt
import httpx
APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"
APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"
async def get_apple_public_keys():
"""Fetch Apple's public keys for JWT validation."""
async with httpx.AsyncClient() as client:
response = await client.get(APPLE_KEYS_URL)
return response.json()["keys"]
async def validate_apple_identity_token(id_token: str) -> dict: """Validate and decode Apple's identity token JWT.""" # Get Apple's public keys apple_keys = await get_apple_public_keys() BLANK # Decode header to find the key ID header = jose_jwt.get_unverified_header(id_token) kid = header["kid"] BLANK # Find matching key key = next(k for k in apple_keys if k["kid"] == kid) BLANK # Verify and decode payload = jose_jwt.decode( id_token, key, algorithms=["RS256"], audience=APPLE_CLIENT_ID, issuer="https://appleid.apple.com", ) BLANK return payload BLANK
@router.post("/auth/apple/callback") async def apple_callback(data: AppleCallbackData): # Validate the identity token identity = await validate_apple_identity_token(data.id_token) BLANK apple_user_id = identity["sub"] email = identity.get("email") BLANK # User info (name) only comes on first authorization name = None if data.user: name_data = data.user.get("name", {}) first = name_data.get("firstName", "") last = name_data.get("lastName", "") name = f"{first} {last}".strip() or None BLANK user = await find_or_create_user( provider="apple", provider_id=apple_user_id, email=email, name=name, ) BLANK token = create_access_token(user.id) return {"token": token, "redirect": "/get-started"} ```
The JWT validation is critical. Without it, anyone could craft a fake identity token. We verify the token against Apple's public keys (fetched from their JWKS endpoint), check the audience matches our client ID, and verify the issuer is https://appleid.apple.com.
Token Strategy: 24-Hour Expiry With Refresh
All four OAuth providers converge to the same output: a 0fee JWT token. The token has a 24-hour expiry with a refresh mechanism:
python# services/auth.py
ACCESS_TOKEN_EXPIRY = timedelta(hours=24)
REFRESH_TOKEN_EXPIRY = timedelta(days=30)
def create_access_token(user_id: str) -> str:
payload = {
"sub": user_id,
"type": "access",
"exp": datetime.utcnow() + ACCESS_TOKEN_EXPIRY,
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def create_refresh_token(user_id: str) -> str: payload = { "sub": user_id, "type": "refresh", "exp": datetime.utcnow() + REFRESH_TOKEN_EXPIRY, } return jwt.encode(payload, SECRET_KEY, algorithm="HS256") BLANK
@router.post("/auth/refresh") async def refresh_token(refresh_token: str): payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=["HS256"]) if payload.get("type") != "refresh": raise HTTPException(401, "Invalid token type") BLANK new_access = create_access_token(payload["sub"]) return {"access_token": new_access, "expires_in": 86400} ```
The 24-hour access token means users do not need to re-authenticate during a working day. The 30-day refresh token means they stay logged in across sessions. When the access token expires, the frontend silently uses the refresh token to get a new one.
Post-Auth Redirect: The /get-started Flow
After successful authentication, all four OAuth flows redirect to /get-started. This is the onboarding stepper where new users:
- Choose a display name
- Create their first application
- Generate their first API key
- Make a test payment
For existing users, the redirect skips to the dashboard. The check is simple:
python# On the frontend
async function handleAuthRedirect(token: string) {
localStorage.setItem("access_token", token);
const user = await fetchCurrentUser();
if (user.apps && user.apps.length > 0) {
// Existing user with apps -> go to dashboard
navigate("/dashboard");
} else {
// New user -> onboarding
navigate("/get-started");
}
}Account Linking Logic
The most nuanced part of multi-provider OAuth is account linking. What happens when someone signs up with Google, and later tries to sign in with GitHub using the same email?
python# services/user.py
async def find_or_create_user(
provider: str,
provider_id: str,
email: str | None,
name: str | None,
) -> User:
provider_column = f"{provider}_id"
# First: check if this provider ID is already linked
user = await db.query(User).filter(
getattr(User, provider_column) == provider_id
).first()
if user:
return user
# Second: check if email matches an existing user
if email:
user = await db.query(User).filter(User.email == email).first()
if user:
# Link this provider to the existing account
setattr(user, provider_column, provider_id)
await db.commit()
return user
# Third: create a new user
user = User(
id=generate_user_id(),
email=email,
name=name,
**{provider_column: provider_id},
)
db.add(user)
await db.commit()
return userThe lookup order matters: provider ID first, then email match, then new account creation. This ensures that a user who links multiple providers always ends up with a single account, not duplicates.
What We Learned
Apple is the most difficult OAuth provider to implement. The popup flow, POST callback, JWT identity token, and first-login-only user info make it fundamentally different from the other three. Budget twice the time for Apple.
Multi-tenant Azure AD is essential for developer platforms. Restricting to a single tenant or personal accounts would exclude a significant portion of developers. The /common endpoint handles all account types.
Account linking should be email-based for developer platforms. Developers expect that their Google account and GitHub account with the same email will result in one 0fee account, not two.
Refresh tokens prevent OAuth re-consent fatigue. Without refresh tokens, users would need to go through the OAuth consent screen every 24 hours. The 30-day refresh token keeps the experience smooth.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.