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).
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:
- Go to the Developer Settings page in GitHub. (GitHub Developer Settings → OAuth Apps)
- Click on New OAuth App.
- 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.
- Finally, click on Register Application.
On the confirmation page, you will see your Client ID. Now, press Generate a new Client secret:
Once the new client secret is generated, you will see it on the confirmation page like this:
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:
- Save it as client-app.js.
- 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
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.
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.
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.
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.
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:
- Seed a cookie on the user’s browser at the beginning of the authorization flow.
- 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:
- API Keys vs OAuth: Which API Authentication Method Is More Secure?
- Configure an OAuth 2.0 Client Credentials Credential Provider | DOCS
- OAuth vs. OIDC: What’s the Difference and When Should You Use Each?
- OAuth 2.0 vs 2.1: What Changed and How to Migrate
Additionally, you can check the following links from external sites:
- The official OAuth page provides current information on OAuth specifications and related work.
- The original RFC 6749 from the Internet Engineering Task Force.
- The draft from OAuth 2.1, which will become an RFC itself.
3-Legged OAuth, Answered
What is 3-legged OAuth?
How does a 3-legged OAuth flow work?
What is the difference between 2-legged and 3-legged OAuth?
What is an authorization code in 3-legged OAuth?
What does the state parameter do in OAuth?