PCSalt
YouTube GitHub
Back to Security
Security · 5 min read

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

RoleWhoExample
Resource OwnerThe userYou
ClientThe applicationPhoto Printer, your mobile app
Authorization ServerIssues tokensGoogle, Auth0, Keycloak
Resource ServerHosts the APIGoogle Photos API, your backend

OAuth 2.0 flows

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
ScopeWhat it grants
openidRequired for OIDC. Returns an ID token
profileName, picture, etc.
emailEmail address
offline_accessReturns a refresh token
Custom scopesapi.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

FeatureOAuth 2.0OpenID Connect
PurposeAuthorizationAuthentication + Authorization
Tells you who the user isNoYes (ID token)
Access tokenYesYes
ID tokenNoYes
Standard scopesNo (custom only)openid, profile, email
UserInfo endpointNoYes

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.