# How OAuth works: GitHub and Google login explained

> How OAuth 2.0 authorization code flow works for GitHub and Google login: redirects, codes, access tokens, scopes, and what happens behind the button.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-06 | Topics: [Networking](https://flaviocopes.com/tags/network/) | Canonical: https://flaviocopes.com/how-oauth-works/

You click "Login with GitHub" on a site you've never used before. GitHub asks you to approve. You click yes, and you're logged in.

You never typed your GitHub password into that app. So what actually happened?

## The players

Three parties are involved in every OAuth login:

- **Your app** — the client that wants to know who you are
- **You** — the user
- **The provider** — GitHub, Google, or whoever holds your account

The goal is simple. Your app gets a limited-permission **access token**. It never sees your password.

The provider handles login. Your app gets proof that you approved access, plus permission to call a few API endpoints on your behalf.

## The authorization code flow

OAuth 2.0 has a few flows. The one behind "Login with GitHub" and "Login with Google" is the **authorization code flow**. Here's the whole thing:

```
User → Your app → Provider (login + approve) → Your app (callback) → Provider (token exchange) → Your app (API call)
```

Let's walk through it step by step.

1. You register your app with the provider, get a **client ID** and **client secret**, and set a **redirect URL**.
2. Your app sends the user to the provider's authorize URL.
3. The user approves. The provider redirects back to your redirect URL with a short-lived **authorization code**.
4. Your server exchanges the code plus client secret for an **access token**.
5. Your server calls the provider's API with that token to get the user's profile.

That's it. Five steps. Let's look at each one with GitHub as the example.

### Step 1: Register your app

Go to GitHub Settings → Developer settings → OAuth Apps. Create an app, set your callback URL (e.g. `https://myapp.com/auth/github/callback`), and GitHub gives you a client ID and client secret.

Keep the secret on your server. Never put it in frontend code.

### Step 2: Send the user to GitHub

Your app builds an authorize URL and redirects the browser there:

```
https://github.com/login/oauth/authorize?client_id=Ov23liABC123&redirect_uri=https://myapp.com/auth/github/callback&scope=read:user&state=random-csrf-token
```

The important query params:

- `client_id` — identifies your app
- `redirect_uri` — must match what you registered
- `scope` — what permissions you're asking for
- `state` — a random string we'll talk about later

The user sees GitHub's login page, then a screen asking "Authorize MyApp?"

### Step 3: GitHub redirects back with a code

After approval, GitHub sends the browser to your redirect URL:

```
https://myapp.com/auth/github/callback?code=abc123def456&state=random-csrf-token
```

That `code` is short-lived. Usually it expires in about 10 minutes. It's useless without your client secret, so an attacker who intercepts it can't do much on their own.

Your server should check that `state` matches what you sent in step 2. More on that below.

### Step 4: Exchange the code for a token

This happens server-side. Your backend POSTs the code to GitHub's token endpoint:

```bash
curl -X POST https://github.com/login/oauth/access_token \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "client_id": "Ov23liABC123",
    "client_secret": "your-client-secret",
    "code": "abc123def456",
    "redirect_uri": "https://myapp.com/auth/github/callback"
  }'
```

GitHub responds with JSON:

```json
{
  "access_token": "gho_xxxxxxxxxxxx",
  "token_type": "bearer",
  "scope": "read:user"
}
```

The same exchange in JavaScript on your server:

```js
const response = await fetch('https://github.com/login/oauth/access_token', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    client_id: process.env.GITHUB_CLIENT_ID,
    client_secret: process.env.GITHUB_CLIENT_SECRET,
    code,
    redirect_uri: 'https://myapp.com/auth/github/callback',
  }),
})

const { access_token } = await response.json()
```

If something goes wrong here — bad code, wrong secret, expired code — you'll get an error response. Check the [HTTP status codes](https://flaviocopes.com/http-status-codes/) and the error body GitHub sends back.

### Step 5: Fetch the user profile

Now your server has a token. Use it to call GitHub's API:

```js
const userResponse = await fetch('https://api.github.com/user', {
  headers: {
    Authorization: `Bearer ${access_token}`,
  },
})

const user = await userResponse.json()
// { login: 'flavio', id: 12345, avatar_url: '...', ... }
```

You now know who logged in. Create a session, store the user in your database, set a cookie — whatever your app needs.

## Google: same flow, plus OpenID Connect

Google uses the same authorization code flow. The authorize URL looks like this:

```
https://accounts.google.com/o/oauth2/v2/auth?client_id=123.apps.googleusercontent.com&redirect_uri=https://myapp.com/auth/google/callback&response_type=code&scope=openid%20email%20profile&state=random-csrf-token
```

The scopes are different. Google login typically asks for `openid email profile` instead of GitHub's `read:user`.

Google also implements **OpenID Connect** on top of OAuth. When you exchange the code, the response includes an **id_token** — a [JWT](https://flaviocopes.com/jwt/) with the user's identity (email, name, Google user ID). You can verify that token instead of making a separate API call, though many apps still hit Google's userinfo endpoint to be safe.

The token exchange endpoint is `https://oauth2.googleapis.com/token`. Same idea: POST the code, client ID, client secret, and redirect URI.

## State and PKCE

Two security pieces worth knowing.

**State** protects against CSRF. Before redirecting to the provider, your app generates a random string and stores it (in a cookie or session). The provider sends it back in the callback. If it doesn't match, reject the request — someone else started that login flow.

**PKCE** (Proof Key for Code Exchange) protects against authorization code interception. Your app generates a random `code_verifier`, sends a hash of it as `code_challenge` in the authorize URL, then sends the original verifier during token exchange. Mobile apps and SPAs can't keep a client secret, so PKCE is required for them. GitHub and Google both support it.

## Scopes: ask for the minimum

Scopes define what the token can do. On GitHub:

- `read:user` — read your public profile (enough for login)
- `repo` — full access to your repositories

For login, `read:user` is all you need. Don't ask for `repo` unless your app actually needs repository access. Users notice, and they'll deny the request.

Google's `openid email profile` scopes are similarly minimal for a basic login button.

## You rarely hand-roll this

In practice you don't write these five steps from scratch every time. Libraries like [Better Auth](https://flaviocopes.com/better-auth/) handle the redirect, callback, token exchange, and session creation for you. You add the provider credentials and call `signIn.social({ provider: 'github' })`.

But knowing the flow helps when things break. Redirect URI mismatch? Check step 1. Callback returns an error instead of a code? The user denied access or your `state` check failed. Token exchange returns 401? Your client secret is wrong or the code expired.

OAuth looks complicated on paper. It's really just a redirect, a code swap, and an API call. Once you've traced it once, debugging auth issues gets a lot easier.
