Table of Contents

3-Legged OAuth (3LO) Explained: How the OAuth Flow Works

TL;DR – 3-legged OAuth (3LO) is an authorization flow in which a user grants an application access to a protected resource through an authorization server. This walkthrough builds a working GitHub OAuth app so you can see how redirects, consent, authorization codes, state validation, and access-token exchange fit together.

Víctor Jiménez
Víctor Jiménez

Technical Writer at RootNode

Summarize:

Read
0%
Minimal 3D illustration of a three-legged OAuth flow connecting a user, client application, and authorization server

Table of Contents

Read
0%

OAuth, and more specifically OAuth 2.0, is the de facto standard for application authorization. With this framework, you can define a flow to grant access to protected resources.

More recently, OAuth 2.1 consolidated the standard by defining security best practices such as Proof Key for Code Exchange (PKCE) and exact URI matching. This way, OAuth 2.1 provides a more secure and consistent flow for delegated authority across applications.

3-legged OAuth is an industry shorthand for an authorization flow where a resource owner (the user) is involved:

  • Client Application (consumer), 
  • User (resource owner), 
  • Authorization server,
  • Resource server (the element that needs authorization to be accessed)

In contrast, 2-legged OAuth involves just two parties (client and server) and no human interaction.

In this article, we’ll explain how 3-legged OAuth works by example. We’ll create a client application in Node/Express for GitHub’s API, and use it to spy on the messages from the OAuth authentication flow.

If you want to see OAuth from a more theoretical perspective, check out What Is OAuth? A Guide to Tokens, Scopes, and AI Agent Access | Aembit 

Building our OAuth 3LO example

For our exercise, we will build and register a client OAuth application in GitHub that will request authorization to access the GitHub API.

There will be four actors in our example:

  • User (resource owner): the fictional user that will also run the client.
  • Client application: a NodeJS/Express application.
  • Authorization server: GitHub.
  • Resource server: GitHub API (https://api.github.com/user).
content 3-legged-oauth diagram

But before we can run any code, we need to register the client as an OAuth app in our GitHub account.

Register the OAuth App in GitHub

The first step is to register our client application on GitHub to generate client credentials (ID and secret) that the client will later use to authenticate with GitHub’s authorization server.

For that:

  1. Go to the Developer Settings page in GitHub. (GitHub Developer Settings → OAuth Apps)
  2. Click on New OAuth App.
  3. In Register a New OAuth App, fill the following fields:
    • Application name: the name you want to use for this OAuth App (e.g., oauthtest)
    • Homepage URL: in this case, add the client homepage http://localhost:8000/
    • Application description: optionally, add a description to identify this app.
    • Authorization callback URL: add your client’s callback URL (http://localhost:8000/callback)
    • Leave unchecked Enable device Flow.
  4. Finally, click on Register Application.
Screenshot showing how to register a new OAuth app in GitHub

On the confirmation page, you will see your Client ID. Now, press Generate a new Client secret:

Screenshot showing client ID

Once the new client secret is generated, you will see it on the confirmation page like this:

Screenshot showing a client secret

Copy both values for Client ID and Client Secret.

Now that we have registered the OAuth App, let’s build the code for the client application.

Building the Client Application

Our client application is written in Nodejs, with Express, and is split into three endpoints:

  • The / endpoint only displays a link to start the OAuth flow.
  • The /callback endpoint handles the first redirect from the authorization server and initiates the flow to validate the client’s identity.
  • The /exchange-token endpoint will take over requesting the access token.

Here’s the code for the client application:

  1. Save it as client-app.js.
  2. Replace the values for YOUR_CLIENT_ID and YOUR_CLIENT_SECRET with the values from the previous step.

We’ll be explaining the code step by step later in the article.

// client-app.js
const express = require("express")
const crypto = require("crypto")

const app = express()
app.use(express.urlencoded({ extended: true })) // needed to read the button's form POST
const PORT = 8000

const CLIENT_ID = "YOUR_CLIENT_ID"
const CLIENT_SECRET = "YOUR_CLIENT_SECRET"
const REDIRECT_URI = "http://localhost:8000/callback" // must exactly match what you registered

const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"

const pendingStates = new Set()

// First endpoint: Client home page, redirects to Github credentials page (Step 3)
app.get("/", (req, res) => {
  // State to avoid CSRF attacks.
  // You are free to strengthen this validation.
  // i.e. Setting a cookie in this request, and checking the value on the callback.
  // This would somehow ensure that the same browser that started the flow is the one getting the authorization later on.
  const state = crypto.randomBytes(8).toString("hex")
  pendingStates.add(state)

  const url = new URL(GITHUB_AUTHORIZE_URL)
  url.searchParams.set("client_id", CLIENT_ID)
  url.searchParams.set("redirect_uri", REDIRECT_URI)
  url.searchParams.set("scope", "read:user") // just enough to read the profile
  url.searchParams.set("state", state)

  console.log("\n=== Step 3: sending user to GitHub's /authorize ===")
  console.log("client_id     :", CLIENT_ID)
  console.log("redirect_uri  :", REDIRECT_URI)
  console.log("scope         :", "read:user")
  console.log(
    "state (ours)  :",
    state,
    "  <- generated now, must come back unchanged in Step 5",
  )
  console.log("full authorize URL:", url.toString())

  res.send(`<a href="${url.toString()}">Log in with GitHub</a>`)
})

// Second endpoint: callback. Authorization Server returns here with an Authorization Code
app.get("/callback", async (req, res) => {
  const { code, state, error, error_description } = req.query

  console.log("\n=== Step 4: GitHub redirected back to /callback ===")
  console.log(
    "authorization code:",
    code,
    "  <- short-lived, single-use, NOT exchanged yet",
  )
  console.log("state (returned)  :", state)

  if (error) {
    console.log(
      "GitHub returned an error instead of a code:",
      error,
      error_description,
    )
    return res
      .status(400)
      .send(`GitHub denied authorization: ${error_description || error}`)
  }
  // Validating the state to prevent CSRF attacks.
  // This is where you would perform any extra checks (i.e. cookies).
  if (!state || !pendingStates.has(state)) {
    console.log("STATE MISMATCH — rejecting, possible CSRF")
    return res
      .status(400)
      .send("Invalid or missing state — possible CSRF, aborting.")
  }
  console.log("state check passed — matches the value generated in Step 3")
  pendingStates.delete(state)

  res.send(`
    <h2>Step 5 complete</h2>
    <p>GitHub redirected the browser back here with an authorization code:</p>
    <pre>${code}</pre>
    <p>Clicking the button below to trade this authorization code for an access token.</p>
    <form method="POST" action="/exchange-token">
      <input type="hidden" name="code" value="${code}" />
      <button type="submit">Exchange code for access token</button>
    </form>
  `)
})

// Third endpoint: Exchange Token. When accessed, exchanges an Authorization code for an Access Token (steps 6 and 7)
app.post("/exchange-token", async (req, res) => {
  const { code } = req.body

  console.log(
    "\n=== Starting step 6: exchanging the code for a token (server-to-server) ===",
  )
  console.log("POST", GITHUB_TOKEN_URL)
  console.log("sending code       :", code)
  console.log("sending client_id  :", CLIENT_ID)
  console.log(
    "sending client_secret:",
    CLIENT_SECRET.slice(0, 4) + "…redacted…",
    " <- never logged in full, never sent to the browser",
  )

  const tokenRes = await fetch(GITHUB_TOKEN_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      redirect_uri: REDIRECT_URI,
    }),
  })

  const tokens = await tokenRes.json()

  if (tokens.error) {
    console.log("token exchange FAILED:", tokens)
    return res
      .status(400)
      .send(`Token exchange failed: ${JSON.stringify(tokens)}`)
  }

  console.log("access_token received:", tokens.access_token)
  console.log("token_type           :", tokens.token_type)
  console.log("scope granted        :", tokens.scope)

  res.send(`
    <h2>Step 7 complete</h2>
    <p>Token response from GitHub's /login/oauth/access_token:</p>
    <pre>${JSON.stringify(tokens, null, 2)}</pre>
  `)
})

app.listen(PORT, () => {
  console.log(`Client app running on http://localhost:${PORT}`)
  console.log(`Visit http://localhost:${PORT} to start the GitHub OAuth flow`)
})

Following the OAuth flow

Run the client application in your terminal with:

node client-app.js

This will display the following message in your terminal:

Client app running on http://localhost:8000
Visit http://localhost:8000 to start the GitHub OAuth flow

Steps 1-5: Requesting an auth code

content 3-legged-oauth diagram

Accessing http://localhost:8000/ will call the client’s / endpoint:

app.get("/", (req, res) => {
  // State to avoid CSRF attacks.
  // You are free to strengthen this validation.
  // i.e. Setting a cookie in this request, and checking the value on the callback.
  const state = crypto.randomBytes(8).toString("hex")
  pendingStates.add(state)

  const url = new URL(GITHUB_AUTHORIZE_URL)
  url.searchParams.set("client_id", CLIENT_ID)
  url.searchParams.set("redirect_uri", REDIRECT_URI)
  url.searchParams.set("scope", "read:user") // just enough to read the profile
  url.searchParams.set("state", state)

  /* Ommited console.log calls*/

  res.send(`<a href="${url.toString()}">Log in with GitHub</a>`)
})

Which displays a simple screen with a Log in with GitHub link.

Screenshot showing a log in with GitHub link

Step 1: If we take a closer look at the source code, we can see that the link points to the GitHub authorization endpoint to start an OAuth flow. The URL contains these parameters:

  • client_id contains the Client ID, so the authentication server knows which client is requesting access.
  • After the user authenticates and consents to provide access to the client, the authorization server will redirect the user’s browser to redirect_uri to continue the OAuth flow.
  • In scope, the client specifies the areas it is requesting access for.
  • Finally, state is a single-use random string that the authorization server must send back. This way, the client can track each access token request, helping it mitigate CSRF attacks.

You are free to complement the state field with extra validation steps to protect against CSRF attacks. We’ll comment on that in the Next Steps section.

Note: The OAuth 2.0 standard requires a response_type indicating which grant type the client is requesting (code, token, or other types defined in extensions to the standard). However, GitHub only supports code, so its documentation omits this parameter.

Step 2: After pressing the link, the parameters are sent to GitHub’s authorization server.

Step 3 and 4: Next, the authorization server requests authentication. It first asks for the user’s credentials, then asks for consent to grant access to the client.

Screenshot of a GitHub sign in screen

Step 5: After the user interaction, the authorization server redirects the user’s browser to the redirect_uri the client sent earlier.

In our case, this leads to the /callback endpoint.

app.get("/callback", async (req, res) => {
  const { code, state, error, error_description } = req.query

  /* Ommited console.log calls*/

  if (error) {
    console.log(
      "GitHub returned an error instead of a code:",
      error,
      error_description,
    )
    return res
      .status(400)
      .send(`GitHub denied authorization: ${error_description || error}`)
  }
  // Validating the state to prevent CSRF attacks.
  // This is where you would perform any extra checks (i.e. cookies).
  if (!state || !pendingStates.has(state)) {
    console.log("STATE MISMATCH — rejecting, possible CSRF")
    return res
      .status(400)
      .send("Invalid or missing state — possible CSRF, aborting.")
  }
  console.log("state check passed — matches the value generated in Step 3")
  pendingStates.delete(state)

The first part of this callback processes the authorization server’s response.

If there were no errors, the client will focus on the state parameter. It validates that it corresponds to an active request, which are stored in the pendingStates array.

If you want to perform extra validations against CSRF attacks, this is the place. We’ll comment on this in the Next Steps section.

Note: The authorization server also sent a code parameter. This is a short-lived and single-use authorization code we’ll exchange in the next steps for the access_token required to use the API.

The second part of the /callback endpoint displays the authorization code and a button to continue the flow:

app.get("/callback", async (req, res) => {
  const { code, state, error, error_description } = req.query

  /* Ommited the code shown earlier*/

  res.send(`
    <h2>Step 5 complete</h2>
    <p>GitHub redirected the browser back here with an authorization code:</p>
    <pre>${code}</pre>
    <p>Clicking the button below to trade this authorization code for an access token.</p>
    <form method="POST" action="/exchange-token">
      <input type="hidden" name="code" value="${code}" />
      <button type="submit">Exchange code for access token</button>
    </form>
  `)
})

This will be shown in the browser like this:

Note: A regular client would continue the flow automatically. However, we coded our client to pause for educational purposes and to let you follow the flow on your time.

Steps 6-7: Exchanging for an access token

Now that the user has consented to provide access to the client, the client needs to validate its identity before receiving an access token.

content 3-legged-oauth diagram

Step 6: Once you press the button, the flow will continue on the /exchange-token endpoint:

app.post("/exchange-token", async (req, res) => {
  const { code } = req.body

  /* Ommited console.log calls*/

  const tokenRes = await fetch(GITHUB_TOKEN_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      redirect_uri: REDIRECT_URI,
    }),
  })

  const tokens = await tokenRes.json()

The client will perform a POST HTTP call to the /access_token endpoint on GitHub’s authorization server. It will send as parameters:

  • The client_id so the server knows which client it is talking to.
  • The client_secret as proof of identity.
  • The authorization code the client received in the previous step.
  • The redirect_uri the client sent on the first request. This will only be used as an extra validation step on the server side to prevent redirection attacks.

Note: The GitHub server breaks the standard on this call in two ways:

  • The specification requires a grant_type field set to authorization_code.
  • GitHub marks the redirect_uri parameter as optional, although strongly recommended.

The second part of the /exchange-token endpoint handles the server response:

app.post("/exchange-token", async (req, res) => {
  const { code } = req.body

  /* Ommited code shown earlier*/

  const tokens = await tokenRes.json()

  if (tokens.error) {
    console.log("token exchange FAILED:", tokens)
    return res
      .status(400)
      .send(`Token exchange failed: ${JSON.stringify(tokens)}`)
  }

  /* Ommited console.log calls*/

  res.send(`
    <h2>Step 7 complete</h2>
    <p>Token response from GitHub's /login/oauth/access_token:</p>
    <pre>${JSON.stringify(tokens, null, 2)}</pre>
  `)
})

If all is correct, the server will respond with a JSON containing the access token that the client can use to access the resource (the GitHub API).

Our client will display it on the console like this (see the last line):

=== Starting step 6: exchanging the code for a token (server-to-server) ===
POST https://github.com/login/oauth/access_token
sending code       : b0367f9a555b1d9c60a6
sending client_id  : Ov23ligYc1KcGf3OwW4U
sending client_secret: 473b…redacted…  <- never logged in full, never sent to the browser
access_token received: gho_Ycxklr62Lm...

Steps 8-9: Accessing the API

We’ll now use this access token to access the resource.

content 3-legged-oauth diagram

Step 8: To demonstrate that the access token is the only thing a client needs to access the resource, we will call the API using curl from a new terminal session.

Just replace YOUR_ACCESS_TOKEN in the following command with the one you obtained in the previous step:

curl \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/user

This also highlights how important it is to protect access tokens and set short expiration dates, as anyone who gains access to an access token can use it to access the resource.

Step 9: The API server will return a JSON representing the user’s personal profile from the GitHub API:

{
  "login": "myuser",
  "id": 2434720,
  "node_id": "MDQ2VZNl2jE3MDQ4Mj8=",
  "avatar_url": "https://avatars.githubusercontent.com/u/2434720?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/myuser",
  "html_url": "https://github.com/myuser",
…

And that’s it! You have successfully created an application which includes all the elements for a 3-legged OAuth exchange:

  • A client application.
  • An authorization server.
  • A state value sent to avoid CSRF attacks.
  • Sending credentials to the Authorization Server.
  • The server sending back an authorization code.
  • The client receiving the authorization code and exchanging it for an access token.
  • The access token being used to access the resource successfully

Feel free to play around with the code, and remember to check the terminal output for extra information on the OAuth elements being exchanged. However, don’t use this code in production, as it’s meant for education purposes only. Use existing libraries for your platform instead.

Next Steps

Perform extra validations against CSRF

The state field’s goal is to ensure that the user who starts the authentication flow is the same one who later on receives the authentication in the callback. However, this protection is not perfect. If a malicious actor somehow gets hold of that value, they would be one step closer to crafting a forged request to the callback.

The OAuth standard allows clients to implement extra validation steps.

One common technique is to:

  1. Seed a cookie on the user’s browser at the beginning of the authorization flow.
  2. Then, in the callback, check that the cookie’s value matches.

With this extra step, only the browser that started the flow would be able to receive the authorization. You can use an existing cookie, like the one used to track the user’s session. However, it’s more secure to use a separate cookie that only exists during the authorization flow.

As an exercise, we invite you to implement such a validation in our example code. We left some comments in steps 1 and 5 marking where you will need to add code.

Add PKCE

GitHub supports PKCE (Proof Key for Code Exchange), which can be sent along with the client secret. By adding a pair of code_verifier/code challenge, you can prevent a malicious third party from retrieving your authorization code inside the flow and accessing the resource on your behalf. This is useful, for example, for public clients (like desktop or mobile apps) that cannot store the client secret in a way that is hidden to the user.

Implement Refresh Tokens

As a security measure, access tokens have an expiration date. If an access token has expired, you can request a new one using the refresh token received. Note, however, that support for this in the authorization server may vary.

If you take a closer look at the token provided at the end of our example:

{
  "access_token": "gho_…",
  "expires_in": 28800,
  "refresh_token": "ghr_…",
  "refresh_token_expires_in": 15897600,
  "token_type": "bearer",
  "scope": "read:user"
}

You can see it expires_in 28800 seconds (20 days).

However, it is accompanied by a refresh_token with a longer expiration that your client may use to request a new access token.

Learn more about OAuth

Check the following resources to continue exploring OAuth:

Additionally, you can check the following links from external sites:

3-Legged OAuth, Answered

What is 3-legged OAuth?

3-legged OAuth, or 3LO, is an authorization flow in which a user is involved in granting a client application access to a protected resource. The flow involves a client application, the user, an authorization server, and a resource server.

How does a 3-legged OAuth flow work?

A 3-legged OAuth flow sends the user to an authorization server to authenticate and approve the requested access. The authorization server then returns an authorization code to the client, which exchanges that code for an access token it can use to access the protected resource.

What is the difference between 2-legged and 3-legged OAuth?

The main difference is user involvement. A 3-legged OAuth flow includes a user who authorizes an application to act on their behalf, while a 2-legged OAuth flow occurs between a client and server without interactive user authorization.

What is an authorization code in 3-legged OAuth?

An authorization code is a short-lived, single-use value returned to the client after the user completes the authorization step. The client exchanges the code with the authorization server to obtain the access token needed to call the protected API.

What does the state parameter do in OAuth?

The <code>state</code> parameter helps protect the OAuth flow against cross-site request forgery attacks. The client generates a random value before redirecting the user to the authorization server and verifies that the same value is returned with the authorization response.

 

Related Reading

Víctor Jiménez
Víctor Jiménez

Víctor Jiménez is an engineer and technical writer who specializes in turning complex engineering concepts into clear, practical explanations. He began his career as a full-stack software engineer, while also working as a MySQL database administrator and certified instructor. After moving into technical marketing, Víctor focused increasingly on educational content for technical audiences. His recent work covers cloud infrastructure, containers and cybersecurity. Outside of work, he experiments with 3D printing, builds LEGO sets and hosts Dungeons & Dragons campaigns.

You might also like

Aembit adds an OpenAI Workload Identity Federation Credential Provider, replacing static sk-proj-… keys with short-lived, identity-bound tokens.
AI agents need identity controls, scoped access, and runtime enforcement before they are trusted with production systems.
A new protocol proposes a clearer way to connect agent identity, delegated authority and human approval for sensitive actions. AAuth is an authentication and authorization protocol for AI agents inspired by OAuth and OIDC. It authenticates and authorizes agents without the need for a human; it lets agents act on behalf of humans with a separate set of credentials (delegation), and optionally supports human approval before granting access (human in the loop).