OAuth 2.0 & OpenID Connect — How Authentication Actually Works
Understand OAuth 2.0 and OpenID Connect from the ground up — flows, tokens, scopes, and how to implement Google/GitHub login in your app without the confusion.
OAuth 2.0 and OpenID Connect are the foundation of every “Login with Google” button, every API with Bearer tokens, and every mobile app that connects to a backend. They’re also the most confusing protocols most developers encounter.
The confusion comes from mixing up OAuth and OIDC, not knowing which flow to use, and treating tokens as black boxes. This post clears it up.
OAuth 2.0 — Authorization, not authentication
OAuth 2.0 is an authorization protocol. It answers: “Does this app have permission to access this user’s data?”
It does NOT answer: “Who is this user?” That’s authentication.
Example: You use a photo printing service. It needs access to your Google Photos. OAuth lets you grant the printing service access to your photos without giving it your Google password.
You → Photo Printer: "Print my Google Photos"
Photo Printer → Google: "Can I access this user's photos?"
Google → You: "Photo Printer wants to access your photos. Allow?"
You → Google: "Yes"
Google → Photo Printer: "Here's an access token"
Photo Printer → Google Photos API: "GET /photos (Bearer token)"
The photo printer gets access to your photos. It doesn’t know your name, email, or anything else about you — just the photos you authorized.
OpenID Connect — Authentication on top of OAuth
OpenID Connect (OIDC) is a layer on top of OAuth 2.0 that adds authentication. It answers: “Who is this user?”
OIDC adds:
- An ID token (JWT) that contains user identity (name, email, etc.)
- A UserInfo endpoint for fetching additional user info
- Standard scopes like
openid,profile,email
When you click “Login with Google,” you’re using OIDC:
Your App → Google: "Authenticate this user (scope: openid profile email)"
Google → User: "Sign in and consent"
User → Google: "Here are my credentials"
Google → Your App: "Here's an authorization code"
Your App → Google: "Exchange this code for tokens"
Google → Your App: "Here's an access token + ID token"
The ID token tells you who the user is. The access token lets you call Google APIs on their behalf.
The key players
| Role | Who | Example |
|---|---|---|
| Resource Owner | The user | You |
| Client | The application | Photo Printer, your mobile app |
| Authorization Server | Issues tokens | Google, Auth0, Keycloak |
| Resource Server | Hosts the API | Google Photos API, your backend |
OAuth 2.0 flows
Authorization Code flow (recommended)
The most secure flow. Used by web apps and mobile apps.
1. App redirects user to authorization server
GET /authorize?response_type=code&client_id=xxx&redirect_uri=xxx&scope=openid+profile
2. User authenticates and consents
3. Authorization server redirects back with a code
GET /callback?code=abc123
4. App exchanges code for tokens (server-to-server)
POST /token
{ grant_type: authorization_code, code: abc123, client_id: xxx, client_secret: xxx }
5. Authorization server returns tokens
{ access_token: "...", id_token: "...", refresh_token: "..." }
The authorization code is short-lived and exchanged server-side. The access token never passes through the browser’s URL bar.
Authorization Code + PKCE (for mobile/SPA)
Mobile apps and single-page apps can’t keep a client_secret safe. PKCE (Proof Key for Code Exchange) replaces it:
1. App generates a random code_verifier and derives code_challenge
code_verifier = random_string(43 chars)
code_challenge = base64url(sha256(code_verifier))
2. App redirects to authorization server with code_challenge
GET /authorize?response_type=code&code_challenge=xxx&code_challenge_method=S256
3. User authenticates, server redirects back with code
4. App exchanges code + code_verifier for tokens
POST /token
{ grant_type: authorization_code, code: abc123, code_verifier: xxx }
5. Server verifies: sha256(code_verifier) == code_challenge
PKCE prevents interception attacks. Even if someone steals the authorization code, they can’t exchange it without the code_verifier.
Client Credentials flow (machine-to-machine)
No user involved. One service authenticates to another:
POST /token
{ grant_type: client_credentials, client_id: xxx, client_secret: xxx, scope: api.read }
Response: { access_token: "..." }
Used for: backend-to-backend API calls, cron jobs, microservice communication.
Tokens
Access token
Grants access to an API. Short-lived (minutes to hours). Can be a JWT or an opaque string.
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
The resource server validates the access token on every request. If it’s a JWT, validation is local (check signature + expiry). If it’s opaque, the server calls the authorization server’s introspection endpoint.
ID token (OIDC only)
A JWT containing user identity claims:
{
"iss": "https://accounts.google.com",
"sub": "1234567890",
"name": "Alice Johnson",
"email": "[email protected]",
"picture": "https://...",
"iat": 1719000000,
"exp": 1719003600,
"aud": "your-client-id"
}
The ID token is for the client app. Never send it to an API as authorization — use the access token for that.
Refresh token
Long-lived token used to get new access tokens without re-authenticating:
POST /token
{ grant_type: refresh_token, refresh_token: xxx, client_id: xxx }
Response: { access_token: "new_token", refresh_token: "new_refresh" }
Store refresh tokens securely (server-side or encrypted storage on mobile). They’re essentially long-lived credentials.
Scopes
Scopes limit what the access token can do:
scope=openid profile email
| Scope | What it grants |
|---|---|
openid | Required for OIDC. Returns an ID token |
profile | Name, picture, etc. |
email | Email address |
offline_access | Returns a refresh token |
| Custom scopes | api.read, api.write, etc. |
Scopes are consent-based. The user sees what the app is requesting and can deny specific scopes.
Implementing “Login with Google” (OIDC)
Step 1: Register your app
Go to Google Cloud Console → APIs & Services → Credentials → Create OAuth Client ID.
You’ll get a client_id and client_secret.
Step 2: Redirect to Google
GET https://accounts.google.com/o/oauth2/v2/auth
?client_id=YOUR_CLIENT_ID
&redirect_uri=http://localhost:8080/callback
&response_type=code
&scope=openid+profile+email
&state=random_csrf_token
state is a random value to prevent CSRF attacks. Verify it when Google redirects back.
Step 3: Handle the callback
Google redirects to your redirect_uri with a code:
GET /callback?code=4/0AX4XfWi...&state=random_csrf_token
Step 4: Exchange code for tokens
val response = httpClient.post("https://oauth2.googleapis.com/token") {
setBody(FormDataContent(Parameters.build {
append("grant_type", "authorization_code")
append("code", code)
append("client_id", clientId)
append("client_secret", clientSecret)
append("redirect_uri", redirectUri)
}))
}
val tokens = response.body<TokenResponse>()
// tokens.accessToken, tokens.idToken, tokens.refreshToken
Step 5: Extract user info from ID token
val idToken = JWT.decode(tokens.idToken)
val userId = idToken.subject // "1234567890"
val email = idToken.getClaim("email").asString() // "[email protected]"
val name = idToken.getClaim("name").asString() // "Alice Johnson"
Now you know who the user is. Create or find the user in your database, create a session, and you’re done.
Common mistakes
1. Using the ID token as an API token
The ID token is for the client to know who the user is. The access token is for API authorization. Don’t send ID tokens to your API.
2. Storing tokens in localStorage
localStorage is accessible to any JavaScript on the page (XSS risk). Store tokens in httpOnly cookies (web) or encrypted storage (mobile).
3. Not validating tokens
Always verify: signature, expiry (exp), issuer (iss), audience (aud). A token with the wrong audience could be a token meant for a different app.
4. Long-lived access tokens
Access tokens should be short-lived (15 min to 1 hour). Use refresh tokens for long sessions. If an access token is stolen, the damage window is limited.
5. Skipping PKCE for mobile/SPA
Without PKCE, authorization codes can be intercepted on mobile (custom URL schemes) or in SPAs (URL bar). Always use PKCE for public clients.
OAuth 2.0 vs OIDC — Quick reference
| Feature | OAuth 2.0 | OpenID Connect |
|---|---|---|
| Purpose | Authorization | Authentication + Authorization |
| Tells you who the user is | No | Yes (ID token) |
| Access token | Yes | Yes |
| ID token | No | Yes |
| Standard scopes | No (custom only) | openid, profile, email |
| UserInfo endpoint | No | Yes |
If you need “Login with X” — use OIDC. If you need “App X can access user’s data on Service Y” — use OAuth 2.0. In practice, OIDC includes OAuth 2.0, so you get both.
Summary
- OAuth 2.0 = authorization (what can this app do?)
- OpenID Connect = authentication (who is this user?)
- Authorization Code + PKCE = the flow you should use for most apps
- Access tokens = for API access (short-lived)
- ID tokens = for user identity (don’t send to APIs)
- Refresh tokens = for getting new access tokens (store securely)
The protocol is complex, but the concepts are straightforward: redirect the user to the auth server, get a code, exchange it for tokens, validate the tokens, and you have a logged-in user with scoped API access.