Guide BackendSecurityAuthSystem Design

How OAuth 2.0 Works, Explained Without the Confusion

OAuth 2.0 confuses most developers because it is poorly explained everywhere. Here is how it actually works, what the flows are for, and where people get it wrong.

On this page

How OAuth 2.0 Works, Explained Without the Confusion

Every time you click “Sign in with Google” on a third-party app, OAuth 2.0 is running. Every time you connect a tool to your GitHub account, OAuth 2.0 is running. Most developers have used it hundreds of times without fully understanding what is happening between those redirects.

The confusion is understandable. OAuth 2.0 specs are dense, most tutorials jump straight into code, and the terminology (resource server, authorization server, grant type) sounds more complicated than the actual concepts.

What OAuth 2.0 Actually Is

OAuth 2.0 is an authorization framework. Not authentication. Authorization.

Authentication answers “who are you?” OAuth 2.0 answers “what is this app allowed to do on your behalf?” That distinction matters and we will come back to it.

The core problem OAuth 2.0 solves: you want to give a third-party app access to some of your data on another service, without giving it your password. Before OAuth, apps would literally ask for your username and password and log in as you. That meant the app had full access to your account, could store your credentials, and if the app got breached, your main account was compromised.

OAuth 2.0 replaces your password with a scoped, time-limited access token. The third-party app never sees your password. It gets a token that only works for specific actions and expires after a set time.

The Four Players

Every OAuth 2.0 flow involves four roles:

Resource Owner: you, the user. You own the data and decide what access to grant.

Client: the third-party app requesting access. A GitHub integration, a calendar app, a Slack bot.

Authorization Server: the service that authenticates you and issues tokens. Google’s auth server, GitHub’s auth server. This is the thing you get redirected to when you click “Sign in with Google.”

Resource Server: the API that holds your actual data. Google Drive, GitHub’s API, your calendar data. Often the same service as the authorization server but conceptually separate.

The Authorization Code Flow

This is the flow you use for most web applications. Walk through it once and every OAuth integration you ever build makes more sense.

Step 1: The redirect

You click “Connect with GitHub” in some app. The app redirects your browser to GitHub’s authorization server with a request that looks like this:

https://github.com/login/oauth/authorize
  ?client_id=abc123
  &redirect_uri=https://yourapp.com/callback
  &scope=read:user,repo
  &state=random_string_xyz
  &response_type=code

scope tells GitHub what permissions the app is requesting. state is a random value the app generated to prevent CSRF attacks. redirect_uri is where GitHub should send the user after they approve.

Step 2: You approve

GitHub shows you a consent screen: “YourApp wants to read your profile and access your repositories.” You click Approve.

Step 3: The authorization code

GitHub redirects your browser back to the app’s callback URL with a short-lived authorization code:

https://yourapp.com/callback?code=abc_auth_code&state=random_string_xyz

The app checks that the state matches what it sent. If it does not match, something went wrong and the flow stops.

Step 4: Code exchange

The app’s backend takes that code and exchanges it for an access token by making a server-to-server request to GitHub:

POST https://github.com/login/oauth/access_token
  client_id=abc123
  client_secret=supersecret
  code=abc_auth_code
  redirect_uri=https://yourapp.com/callback

GitHub responds with an access token and optionally a refresh token.

Step 5: API calls

The app now uses that access token to call GitHub’s API on your behalf:

GET https://api.github.com/user
Authorization: Bearer gho_access_token_here

The authorization code is single-use and expires in minutes. The actual access token is what the app uses going forward. This two-step process exists because the authorization code travels through the browser (visible in URLs and browser history), but the token exchange happens server-to-server where the client secret can be verified.

Why There Are Multiple Flows

The Authorization Code flow assumes the app has a server-side backend that can keep a client secret. Not all apps do.

Authorization Code with PKCE is for mobile apps and single-page applications that cannot safely store a client secret. PKCE (Proof Key for Code Exchange) replaces the client secret with a dynamically generated code verifier. The app generates a random string, hashes it, sends the hash with the initial request, and sends the original string during the code exchange. GitHub can verify they match without needing a stored secret. Use this for any public client.

Client Credentials is for machine-to-machine communication with no user involved. A backend service calling another backend service. There is no redirect, no consent screen, no user. The client just sends its credentials directly to the authorization server and gets a token back. Use this for service-to-service APIs.

Device Authorization is for devices with no browser, like a TV or a CLI tool. The device displays a short code and a URL. You go to that URL on your phone, enter the code, approve access. The device polls the authorization server until it gets a token. This is what GitHub CLI uses when you run gh auth login.

OAuth 2.0 vs OpenID Connect

This is where most developers get tangled.

OAuth 2.0 is about authorization. It tells an app what it can access. The access token it issues does not contain information about who you are. These access tokens are frequently implemented as JSON Web Tokens (JWT) to support stateless validation.

OpenID Connect (OIDC) is a thin layer on top of OAuth 2.0 that adds authentication. When you add openid to your requested scopes, the authorization server returns an additional ID token alongside the access token. The ID token is a JWT that contains your identity: who you are, your email, your profile information.

When you see “Sign in with Google” and you end up logged into an app, that app is using OIDC, not plain OAuth 2.0. OAuth 2.0 got you access. OIDC told the app who you are.

If you are building a login system, you want OIDC. If you are building an integration that needs to call an API on a user’s behalf, you want OAuth 2.0. In practice, most auth providers support both simultaneously.

Where OAuth 2.0 Goes Wrong

Skipping state validation. The state parameter exists specifically to prevent CSRF attacks on the OAuth callback. If your app does not generate a random state value, send it with the authorization request, and verify it when the callback comes back, an attacker can trick a user into connecting a victim’s account to the attacker’s data. Many OAuth tutorials skip this step and it causes real vulnerabilities.

Storing access tokens in localStorage. Same issue as JWTs. Any JavaScript on your page can read localStorage, including injected scripts. Tokens belong in memory or HttpOnly cookies.

Requesting too many scopes. Apps routinely request more permissions than they need because it is easier than thinking carefully about minimum access. Every scope you request is a permission the user is granting. If your app only needs to read a user’s email, requesting full account access is poor practice and makes users less likely to approve.

Confusing OAuth with authentication. An access token tells you an app is authorized to act on behalf of a user. It does not tell you who that user is. If you are using an access token alone to identify users, you have a security problem. Use the ID token from OIDC or call the userinfo endpoint.

Not handling token expiry. Access tokens expire. If your app does not handle 401 responses by refreshing the token or re-initiating the flow, users will hit broken states. Build refresh token logic from the start.

The One Thing to Take Away

OAuth 2.0 delegates access without delegating credentials. The user approves, the authorization server issues a scoped token, and the app uses that token instead of a password. The indirection through the authorization code, the state parameter, and the server-side token exchange exist to make sure the token never travels through a channel an attacker could intercept. Once you see why each step is there, the flow stops feeling bureaucratic and starts making sense.