← Back to all articles

OAuth2 Authorization Code: What Third-Party Login Does

SecurityBeginner

What "log in with X" actually does

When you click "Continue with Google", you are not handing the app your Google password. You are telling Google: this app may access these specific things on my behalf, for now. That is delegated authorization, and it is the entire point of OAuth2.

The distinction matters because the common failure is treating OAuth2 as authentication. It authorizes access; by itself it does not tell you who the user is. OpenID Connect adds that on top, with an ID token.

The four roles

RoleIn practice
Resource ownerThe user
ClientYour application
Authorization serverGoogle, Auth0, Keycloak
Resource serverThe API holding the data

The authorization-code flow, step by step

  1. The client redirects the browser to /authorize with client_id, redirect_uri, scope, state and, for public clients, a PKCE challenge.
  2. The user authenticates with the authorization server and consents.
  3. The server redirects back to redirect_uri with a short-lived, single-use code and the same state.
  4. The client verifies state, then exchanges the code at /token over a back channel, authenticating with client_secret or a PKCE verifier.
  5. The server returns an access token, optionally a refresh token and, for OIDC, an ID token.

Why the extra hop? Because the browser is hostile territory. The code travels through the front channel where it can be observed; the exchange happens server to server where the secret stays secret. Returning the token directly in the redirect would let any script on the page read it.

PKCE is not optional anymore

Public clients, meaning SPAs and mobile apps, cannot keep a client_secret. Anyone can extract it from the bundle. PKCE replaces the secret with a per-request proof:

// 1. generate a random verifier
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));

// 2. send its SHA-256 hash as the challenge
const challenge = base64url(
  await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)),
);

// 3. at /token, send the original verifier
// the server hashes it and compares with the stored challenge

An attacker who intercepts the code still lacks the verifier, so the exchange fails. Use S256, never plain. Watch for a server that accepts an exchange without the verifier even though one was sent, that is a downgrade.

state and nonce

state is a CSRF token for the redirect: generate it per request, store it in the session, and reject a callback whose state does not match. nonce is the OIDC equivalent for the ID token and prevents replay of an old one. Skipping state is one of the most common real-world bugs.

Two tokens, two lifetimes

The access token is short-lived, minutes, and is what APIs accept. The refresh token is long-lived, stored securely, and can be revoked. Rotate the refresh token on every use and treat reuse of an old one as a breach: revoke the entire family.

Keep authorization codes short-lived too, 30–60 seconds, and single-use. A reused code means the grant was intercepted; invalidate every token it produced.

Attacks that actually happen

  • Redirect URI mismatch. If the server allows a prefix or a wildcard, an attacker redirects the code to their host. Require exact matching.
  • Open redirect on the client. An unvalidated return_to parameter can bounce the code elsewhere.
  • Code interception. Custom URL schemes on mobile can be claimed by another app. PKCE and app-bound claims mitigate it.
  • Token leakage through Referer. Tokens in query strings end up in logs and referrer headers. Keep them in headers or HttpOnly cookies.
  • Confused deputy. A valid token for the wrong audience is accepted. Validate aud.

Logout and revocation

Clearing frontend storage is not logout. Revoke the refresh token at the authorization server, invalidate the session, and, if you keep a deny list, add the access token's jti until it expires. When a user revokes a third-party app, every token from that grant must die, not just the stored record.

Identity is not permission

A token answers who the caller is and what they may reach. Whether they may edit this specific record is a business decision checked at the resource on every call. Embedding permissions in the token feels efficient until a permission change has to wait for expiry.

Client types and secret handling

Confidential clients run on a server and can keep a secret. Public clients run in a browser or on a device and cannot. The distinction decides the flow: confidential clients may use a client secret, public clients must use PKCE, and neither should ship a secret in a mobile binary or a JavaScript bundle. A secret anyone can extract is not a secret, it is a client identifier.

Scope design

Scopes are the vocabulary of delegated access. Keep them coarse enough to understand and fine enough to revoke: profile:read is useful, everything is not. Request the minimum at each step and ask for additional scopes only when the user reaches the feature that needs them, rather than in one oversized consent screen.

If a scope grants a write, say so in the consent text. Users revoke apps that surprise them, and a revoked app is a lost integration.

OIDC in one paragraph

OAuth2 authorizes access. OpenID Connect adds an identity layer: the same flow plus an id_token JWT containing sub, iss, aud, exp and nonce. Validate it like any other JWT against the issuer's JWKS, and never use the access token as proof of identity. The sub is the stable user identifier; email can change.

Multi-tenant issuers

If your system accepts tokens from more than one issuer, such as a per-tenant identity provider, validate iss against an explicit allow-list and fetch keys from the matching JWKS. The classic bug is accepting any issuer whose signature verifies, which lets a token from a partner tenant impersonate a user in yours.

Testing the flow

Exercise the failure paths, not just the happy path: a mismatched state, an expired code, a reused code, a redirect URI with a trailing slash, a token for the wrong audience and a revoked refresh token. Those are the paths attackers use, and the ones that silently return a session when they should refuse.

The takeaway

OAuth2 delegates access without sharing passwords. Use the authorization-code flow, PKCE for public clients, exact redirect matching and state; keep access tokens short and refresh tokens rotatable; and remember that authorization at the resource is still your job.

Try it: UUID generator for state and PKCE values.