A deep dive into OAuth 2.0
By Flavio Copes
Learn how OAuth 2.0 authorization code flow, PKCE, state, scopes, access tokens, OpenID Connect, sessions, and social login work in practice.
You click “Continue with GitHub” on a site you have never used before.
GitHub asks you to approve the app. You click a button, return to the site, and you are logged in.
You never gave the site your GitHub password. The site did not need to implement GitHub’s login form. GitHub did not give the site unrestricted access to your account.
OAuth made that possible.
The visible part is two redirects. Behind those redirects, the browser, your application, and the provider exchange several short-lived values. Each value has one job.
In this tutorial, we will trace the complete flow. We will use GitHub for the concrete example, compare it with Google, add the security protections a modern implementation needs, and finish with the way I use OAuth in my own projects.
sequenceDiagram
actor User
participant Browser
participant App as Your application
participant Provider as GitHub
User->>Browser: Continue with GitHub
Browser->>App: Start login
App-->>Browser: Redirect to GitHub
Browser->>Provider: Authorize request
User->>Provider: Log in and approve
Provider-->>Browser: Redirect with code
Browser->>App: Callback with code
App->>Provider: Code + PKCE verifier
Provider-->>App: Access token
App->>Provider: Request profile
Provider-->>App: GitHub user
App-->>Browser: Session cookie
What OAuth is
OAuth 2.0 is an authorization framework.
It lets one application receive limited access to resources controlled by another service. The user approves that access without sharing their password with the application.
Imagine a photo-printing app. It needs permission to read selected photos from a storage service. It does not need the user’s storage password, email, billing settings, or deletion rights.
OAuth gives the app a token with a defined set of permissions.
The original OAuth 2.0 framework is defined by RFC 6749. The current security guidance is RFC 9700, published in 2025. Modern implementations should follow both.
OAuth is not authentication
This distinction matters.
OAuth answers this question:
May this application access this resource?
Authentication answers a different question:
Who is this user?
Applications often use OAuth for login anyway. They receive an access token, call a provider profile endpoint, and use the returned provider ID as the user’s identity.
That is how GitHub OAuth login commonly works.
Google login normally uses OpenID Connect, or OIDC. OpenID Connect adds an identity layer on top of OAuth 2.0. It returns a signed ID token containing identity claims.
If your goal is login and the provider supports OpenID Connect, use it. Do not invent identity rules from arbitrary OAuth API responses.
The five actors
OAuth documentation uses precise names. Understanding them makes the specification much easier to read.
Resource owner
The resource owner is the person who can approve access.
In our example, that is you.
Client
The client is the application asking for access.
The word does not mean browser here. A server-rendered application can be an OAuth client.
Authorization server
The authorization server authenticates the user, collects consent, and issues codes and tokens.
For GitHub OAuth, GitHub performs this role.
Resource server
The resource server hosts the protected API.
GitHub’s REST API accepts the access token and returns the user’s profile. The authorization server and resource server can belong to the same company, but they are different roles.
User agent
The user agent is normally the browser.
It follows redirects between the client and authorization server. The browser carries the authorization code back, but a server-side application performs the token exchange.
flowchart LR
U["Resource owner"] --> B["Browser"]
B --> C["OAuth client"]
B --> A["Authorization server"]
C --> A
C --> R["Resource server"]
Register the application first
Before a provider accepts OAuth requests, you register the application.
The provider gives you a client ID. A confidential server-side client also receives a client secret.
The client ID is public. It identifies the application. It is fine for users to see it in an authorization URL.
The client secret is a credential. Store it on the server or in your platform’s secret store. Never place it in browser JavaScript, a mobile binary, a public repository, or a URL.
You also register one or more callback URLs:
https://app.flaviocopes.com/auth/github/callback
The provider must compare the callback against the registered value. Current OAuth security guidance requires exact string matching, apart from a limited localhost exception for native apps.
This prevents an attacker from changing the destination and receiving the authorization code.
Public and confidential clients
A confidential client can protect credentials. A traditional web application with a backend is the common example.
A public client cannot keep a secret. Browser-only apps, installed desktop apps, and mobile apps ship code to the user. Any embedded secret can be extracted.
Calling a string CLIENT_SECRET does not make it secret.
Public clients rely on PKCE instead of pretending they can protect a shared credential. Modern confidential clients should use PKCE too.
The authorization code flow
The authorization code flow separates browser-facing work from the token exchange.
The browser receives a short-lived authorization code. The access token is returned through a direct request from the client to the authorization server.
This keeps the token out of the callback URL and browser history.
The flow has these stages:
- The client creates a login transaction.
- The browser visits the authorization endpoint.
- The user authenticates and approves access.
- The provider redirects the browser back with a code.
- The client validates the transaction.
- The client exchanges the code for tokens.
- The client uses the access token at the resource server.
- The client creates its own application session.
Let’s build each part.
Create state and PKCE values
Before redirecting, the application creates three values:
state, which binds the callback to this browser sessioncode_verifier, a high-entropy secret for this transactioncode_challenge, a SHA-256 digest of the verifier
The application stores state and code_verifier in a short-lived server session or protected cookie.
Here is a browser-compatible implementation using the Web Crypto API:
function randomBase64Url(bytes = 32) {
const data = crypto.getRandomValues(new Uint8Array(bytes))
const binary = String.fromCharCode(...data)
return btoa(binary)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '')
}
async function sha256Base64Url(value) {
const data = new TextEncoder().encode(value)
const digest = await crypto.subtle.digest('SHA-256', data)
const binary = String.fromCharCode(...new Uint8Array(digest))
return btoa(binary)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '')
}
const state = randomBase64Url()
const codeVerifier = randomBase64Url(64)
const codeChallenge = await sha256Base64Url(codeVerifier)
Use a cryptographically secure random generator. Math.random() is not suitable for authentication tokens.
RFC 9700 recommends PKCE for confidential clients and requires it for public clients. Use the S256 challenge method.
Redirect the browser to GitHub
GitHub’s authorization endpoint is:
https://github.com/login/oauth/authorize
Build the URL using URL and URLSearchParams. This avoids hand-written escaping mistakes:
const authorizeUrl = new URL(
'https://github.com/login/oauth/authorize',
)
authorizeUrl.search = new URLSearchParams({
client_id: env.GITHUB_CLIENT_ID,
redirect_uri: 'https://app.flaviocopes.com/auth/github/callback',
scope: 'read:user user:email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
}).toString()
return Response.redirect(authorizeUrl, 302)
The browser leaves your application and visits GitHub.
Each parameter has one job:
client_ididentifies the appredirect_urisays where the callback must goscoperequests permissionsstatebinds the response to this login transactioncode_challengebinds the future code exchange to the verifiercode_challenge_methodsays how the challenge was created
Do not put a client secret in this URL.
The provider authenticates the user
GitHub now controls the page.
If the user is not signed in, GitHub authenticates them. Your application never sees those credentials. If GitHub requires MFA or a passkey, that happens entirely on GitHub’s origin.
GitHub then shows the permissions requested by the app. The user can approve or deny them.
Consent and authentication are separate events. A user might already be signed in but still need to approve a new scope.
The callback contains a code
After approval, GitHub redirects the browser to the registered callback:
https://app.flaviocopes.com/auth/github/callback?code=abc123&state=xyz789
The authorization code is not the access token.
It is short-lived, bound to the client and redirect URI, and intended for one use. The authorization server must reject a second exchange.
If the user denies access, the callback contains an error instead:
https://app.flaviocopes.com/auth/github/callback?error=access_denied&state=xyz789
Handle cancellation as a normal outcome. Do not turn it into a server exception page.
Validate the callback before exchanging anything
The callback handler should reject the request unless the transaction is valid.
Check these conditions:
stateexists.- It exactly matches the stored value.
- The transaction has not expired.
- The transaction has not already been used.
- The callback has a code and no provider error.
Compare sensitive values using a timing-safe method when your runtime provides one. More importantly, make the stored state high entropy, short-lived, and single-use.
After validation, delete the transaction. A browser refresh should not replay the login.
flowchart TD
C["OAuth callback"] --> E{"Provider error?"}
E -->|yes| D["Show a safe login message"]
E -->|no| S{"State matches<br/>unused transaction?"}
S -->|no| R["Reject callback"]
S -->|yes| T["Exchange code with verifier"]
Exchange the code for an access token
The server sends a direct POST request to GitHub’s token endpoint:
https://github.com/login/oauth/access_token
Example:
const tokenResponse = await fetch(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: 'https://app.flaviocopes.com/auth/github/callback',
code_verifier: storedCodeVerifier,
}),
},
)
Check the HTTP response before reading the token:
if (!tokenResponse.ok) {
throw new Error('GitHub token exchange failed')
}
const token = await tokenResponse.json()
The response contains an access token, its type, and the granted scope.
Do not log the response body. Do not send the access token to analytics or an error tracker.
PKCE matters here. The authorization server hashes code_verifier and compares it with the challenge stored when the code was issued. A stolen code is useless without the verifier.
Call the protected API
The client can now call GitHub’s resource server:
const profileResponse = await fetch(
'https://api.github.com/user',
{
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token.access_token}`,
'X-GitHub-Api-Version': '2022-11-28',
},
},
)
Use the Authorization header. Never put bearer tokens in query parameters. URLs can leak through history, logs, referrer headers, screenshots, and monitoring systems.
The profile contains a stable GitHub numeric ID. Use the provider name plus that ID as the external account key.
Do not use a username as the durable identity. Users can rename accounts.
Create your own session
OAuth does not log the user into your application by itself.
Your application must now:
- Find or create a local user.
- Link the GitHub account to that user.
- Create an application session.
- Set a secure session cookie.
- Redirect to the intended page.
flowchart LR
P["Provider profile"] --> A["Linked account"]
A --> U["Local user"]
U --> S["Application session"]
S --> C["Secure cookie"]
The browser normally receives your session identifier, not the GitHub access token.
Use an HttpOnly, Secure, and appropriate SameSite cookie. Rotate the session when authentication completes to prevent session fixation.
OAuth tokens and application sessions are different
An access token authorizes calls to the provider’s resource server.
An application session authenticates requests to your own app.
They can have different lifetimes and revocation behavior. Logging out of your app does not necessarily revoke the GitHub authorization. Revoking GitHub access does not automatically delete every local session unless your app detects and handles it.
Keep this boundary explicit in your data model.
Google login and OpenID Connect
Google uses the same authorization code shape, but login normally requests OpenID Connect scopes:
openid email profile
The authorization endpoint is:
https://accounts.google.com/o/oauth2/v2/auth
The token endpoint is:
https://oauth2.googleapis.com/token
After the code exchange, Google returns an access token and an ID token.
The ID token is a signed JWT containing claims such as:
iss, the issuersub, the stable Google account identifieraud, the intended clientexp, the expiration timeemailandemail_verified, when requested
Do not decode the JWT and trust the JSON. Verification must check the signature, issuer, audience, expiration, and nonce where used.
Use a maintained OpenID Connect library. It handles key discovery and rotation correctly.
Access tokens, ID tokens, and refresh tokens
These tokens are easy to confuse.
| Token | Audience | Purpose |
|---|---|---|
| Access token | Resource server | Authorize API requests |
| ID token | OAuth client | Describe an authenticated identity |
| Refresh token | Authorization server | Request a new access token |
| Session token | Your application | Authenticate local requests |
An ID token is not an API credential. An access token is not proof that a JWT was intended for your client unless the protocol and validation establish that.
Refresh tokens are long-lived and sensitive. Store them encrypted or inside a protected server-side credential store. For public clients, current guidance requires sender-constrained refresh tokens or rotation that detects replay.
Only request offline access when the application needs to act after the user leaves.
Scopes and least privilege
A scope names a permission the client wants.
For basic GitHub login, read:user reads profile data. Private email addresses require user:email. The broad repo scope grants extensive repository access and is not appropriate for a login button.
Ask for the smallest set needed now.
If a future feature needs more access, request it when the user enables that feature. This is incremental authorization.
Scopes are not your application’s roles. A GitHub scope does not make someone an administrator in your app. Your database still controls local authorization.
What state protects
The state parameter connects the callback to the browser that started the flow.
Without it, an attacker can start an OAuth flow for their own account and trick a victim’s browser into visiting the attacker’s callback URL. The application might then bind the victim’s browser session to the attacker’s provider account.
Generate a new value for each attempt. Bind it to the browser session. Expire it quickly. Delete it after use.
Do not use state as an unsigned bag of redirect data. If you want to return the user to /dashboard, store that path with the server-side transaction and allow only local destinations.
What PKCE protects
PKCE binds the authorization request to the token exchange.
The client sends only the derived challenge through the browser. It keeps the original verifier until the callback.
sequenceDiagram
participant Client
participant Browser
participant Provider
Client->>Client: Create verifier
Client->>Client: SHA-256 → challenge
Client-->>Browser: Redirect with challenge
Browser->>Provider: Authorization request
Provider-->>Browser: Redirect with code
Browser->>Client: Code
Client->>Provider: Code + verifier
Provider->>Provider: Hash verifier and compare
Provider-->>Client: Token
An attacker who intercepts only the code cannot redeem it.
PKCE does not replace every validation step. You still need exact redirect URIs, secure token storage, correct issuer handling, and safe sessions.
Flows you should avoid
OAuth 2.0 originally described several grants that modern applications should not start using.
Implicit grant
The implicit grant returns an access token through the browser redirect. That exposes the token to URLs and browser-controlled code.
RFC 9700 says clients should use authorization code flow instead. Google also strongly discourages response types that return access tokens in URLs.
Resource owner password credentials
This grant asks the application to collect the user’s provider username and password.
It defeats the main OAuth boundary and does not work well with MFA, passkeys, federation, or changing authentication policy. Current security guidance says it must not be used.
Client credentials for user login
Client credentials authenticate an application acting as itself. There is no user approval or user identity.
Use it for machine-to-machine access, not “Sign in with GitHub.”
Common security mistakes
Loose redirect URI matching
Do not accept wildcard subdomains, arbitrary query redirects, or prefix matching. Register exact callback URLs.
Reusing state or PKCE values
Each login attempt needs a new transaction. Single-use values make replay easier to detect and contain.
Putting secrets in the frontend
A SPA, desktop app, or mobile app is a public client. Use authorization code flow with PKCE. Do not ship a client secret.
Logging codes and tokens
Redact authorization headers, callback query strings, token responses, cookies, and provider error details before they reach logs.
Trusting email as the only account key
Emails can change. Some providers do not verify every email. Link accounts using the issuer/provider and stable subject identifier.
Automatically linking matching emails
Two providers returning the same email does not always prove both accounts belong to the same person. Require a signed-in user or another verified linking step for sensitive applications.
Open redirects after login
Never redirect to an arbitrary next URL supplied by the browser. Allow local paths or a small explicit allowlist.
Treating denial as an internal error
Users can cancel consent. Return them to login with a clear message and no leaked provider details.
How I use OAuth
I use OAuth through maintained authentication libraries instead of implementing the protocol directly.
StackPlan is an Astro application running on Cloudflare Workers. It uses Better Auth with D1 and Drizzle. GitHub is configured as a social provider on the server:
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
}
The login form posts to an Astro endpoint. That endpoint asks Better Auth to create the provider URL, then redirects the browser:
const { url } = await auth.api.signInSocial({
body: {
provider: 'github',
callbackURL: '/dashboard',
},
headers: request.headers,
})
return Response.redirect(url, 302)
Better Auth owns state, provider callbacks, token exchange, linked accounts, and sessions. My code owns the product decision: after login, send the user to the dashboard.
I used another version of the same pattern in a React and Supabase project. The frontend called signInWithOAuth() with GitHub and a callback URL. Supabase acted as the authentication layer and returned the application session.
The implementation changed. The protocol did not.
Understanding the protocol is still valuable. It tells me where to look when a callback fails:
- Provider rejects the first redirect: client ID, scope, or redirect URI.
- Callback state fails: transaction cookie, hostname,
SameSite, or replay. - Token exchange fails: code, verifier, secret, or redirect URI.
- Provider profile fails: token, scope, audience, or API policy.
- App session disappears: cookie forwarding or session configuration.
My Better Auth on Astro and Cloudflare tutorial goes into the project-specific setup.
When I would not use social login
OAuth adds a dependency on the provider.
I would not make GitHub login the only path for a product aimed at people without GitHub accounts. I would not use Google-only login when account portability matters. I would not request repository or calendar access just to avoid building a normal login flow.
Email magic links, passkeys, or email and password can be a better fit. The product and audience decide.
For internal tools, an organization’s OpenID Connect provider can be ideal because it centralizes account lifecycle and MFA policy.
Debug the flow one boundary at a time
OAuth errors become manageable when you identify the failing boundary.
Inspect the authorization request
Check the provider URL in DevTools. Confirm the exact client ID, redirect URI, scope, state, and PKCE challenge.
Do not paste a real authorization URL into public issue trackers. It can contain transaction data.
Inspect the callback
Check whether it contains code or error. Confirm the returned state before doing anything else.
Inspect the server-side exchange
Log only the status, provider error code, and a request correlation ID. Redact the code, verifier, secret, and response tokens.
Inspect cookies
Verify domain, path, Secure, HttpOnly, SameSite, and expiration. A correct OAuth exchange followed by a missing cookie looks like a failed login.
Inspect the local account link
Store provider, provider account ID, and local user ID separately. Make the pair unique so callbacks cannot create duplicate links.
Test the unhappy paths
A login button working once is not enough.
Test these cases:
- user denies consent
- callback has the wrong state
- authorization code is reused
- transaction expires
- PKCE verifier is missing or wrong
- provider returns an email without verification
- linked local account already exists
- session cookie cannot be set
- provider API is unavailable
- requested scope is not granted
- post-login destination is external
Mock the provider boundary in automated tests. Keep one real end-to-end test against a dedicated provider application when practical.
Never run OAuth tests with production client secrets or real customer accounts.
The mental model to keep
OAuth is delegated authorization.
The browser carries the user to the provider and brings back a short-lived code. The server validates the transaction and swaps that code for a token. PKCE binds those two halves together. The access token calls the provider API. Your application then creates its own user session.
OpenID Connect adds identity to that process through a signed ID token.
Use authorization code flow. Use PKCE. Validate state, redirect URI, issuer, and tokens. Ask for the smallest scopes. Keep secrets and tokens away from URLs and logs.
And use a maintained library in production.
You should understand every boundary. You should not have to reimplement all of them.
Want me to talk about your product? You can sponsor this site.
Related posts about network: