OAuth Simplified: A Hands-On Breakdown
Introduction
Hey there, in this blog post I'll try to simplify how OAuth works and break down what actually happens behind the scenes.
So I built a small server and a segment of the client app that would handle the OAuth request. I decided to take this approach because I couldn't really pinpoint the attack vectors of OAuth with just the theory of how it works. I needed to build it in order to understand how to break it. Anyhow, enough with the introduction let's get into it.
So before we start anything, let's make sure you understand the terminology that will be used. Additionally, I'll give you a mental model of the context in which we will be implementing the OAuth functionality:
Terminology
The Frontchannel (The User's Browser)
The Frontchannel is like a public courier. When the Auth Server wants to send a code to the Client App, it gives it to the browser (the courier) via a URL redirect.
The Risk: Because the data is in the URL, it's visible in browser history, server logs, and can be intercepted by malicious browser extensions.
Analogy: Sending a postcard. Anyone who handles the postcard can read what's written on the back.
The Backchannel (Server-to-Server)
The Backchannel is like a private secure line. Once the Client App has the temporary code, it calls the Auth Server directly over a secure HTTPS connection (using a library like axios or fetch).
The Security: This connection is encrypted. The user never sees the data being exchanged (like the code_verifier or the access_token).
Analogy: A private phone call between two offices. No one on the street knows the conversation is even happening.
In this system, the flow moves between the User's Browser (Frontchannel) and Server-to-Server (Backchannel) to ensure security.
Mental Model
To make it clear, the server I built is a Custom OAuth 2.0 Authorization Server using the PKCE extension.
While Google acts as a "Public Identity Provider" for the whole world, this server is currently a "Private Identity Provider." Here is the exact context where this type of server is used:
1. The "Internal Ecosystem" Context
This is the most common real-world use case. Imagine you are building a company called "TechCorp" that has:
- A Main API (Resource Server) that holds user data
- A Mobile App (iOS/Android)
- A Web Dashboard (React/SPA)
- A Desktop Tool
Instead of writing login logic for each app, you build one Authorization Server (the one used here). All your different apps "Sign in with TechCorp" by talking to this single server. It centralizes your security.
2. The "Third-Party Developer" Context
Context: You have a platform (like a CRM or E-commerce engine) and you want outside developers to build "Apps" or "Plugins" for it.
Role: You give those developers a client_id, and they use the flow you built to let users "Authorize" their third-party apps to access your platform's data.
Why I used PKCE specifically?
This server is specifically designed for Public Clients. These are apps where the source code is visible to the user (like a Mobile App or a React site).
- Without PKCE: A hacker could intercept the
codefrom the browser and use it. - With PKCE: Even if they steal the
code, they can't use it because they don't have thecode_verifier.
UML Diagram
Step 1: The Setup (Client App Internal)
Before any request is made, the Client App prepares a "secret handshake."
Functionality: The client generates a code_verifier (a random string) and a code_challenge (a hash of that string).
Purpose: To prove later that the app that started the login is the same one that finishes it.
// Step 1: The Setup (Client App Internal)
// Helper: Generate a random string for PKCE
const generateRandomString = () => crypto.randomBytes(32).toString('hex');
// Helper: Hash the string for PKCE (S256)
const generateCodeChallenge = (verifier) => {
return crypto.createHash("sha256").update(verifier).digest("base64url");
};
Step 2: The Authorization Request (Frontchannel)
Endpoint: GET http://localhost:4000/authorize
The Request: The browser is redirected from the Client to the Auth Server with parameters like response_type, client_id, redirect_uri, and the code_challenge.
Functionality: The Auth Server checks if the client_id exists and if the redirect_uri is on the pre-approved "Allowlist."
Storage: The Server generates a temporary authorizationCode and saves the code_challenge in its Map, linked to that code.
// Step 2: The Authorization Request (Frontchannel)
app.get("/login", (req, res) => {
// 1. Create PKCE Verifier and Challenge
currentVerifier = generateRandomString();
console.log("verifier :" + currentVerifier)
const challenge = generateCodeChallenge(currentVerifier);
console.log("code challenge :" + challenge)
// 2. Build the Auth Server URL
const authUrl = `${AUTH_SERVER_URL}/authorize?` +
`response_type=code&` + // specifying the grant type
`client_id=${CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
`code_challenge=${challenge}&` +
`code_challenge_method=S256`;
// 3. Send user to the Auth Server
res.redirect(authUrl);
});
app.get("/authorize", (req, res) => {
const {
response_type,
client_id,
redirect_uri,
code_challenge, //the hashed code challenge
code_challenge_method // specification of the hash used
} = req.query;
// 1. Validate response type
if (response_type !== "code") {
return res.status(400).send("Unsupported response_type");
};
// 2. Validate client
const client = clients[client_id];
if (!client) {
return res.status(400).send("Invalid client_id");
};
// 3. Validate redirect URI
if (!client.redirectUris.includes(redirect_uri)) {
return res.status(400).send("Invalid redirect_uri"); // checking the redirect uri against the allow list
};
// 4. Enforce PKCE
if (!code_challenge || code_challenge_method !== "S256") {
return res.status(400).send("PKCE required");
};
// ---- Fake login success ----
const authorizationCode = crypto.randomBytes(32).toString("hex"); // Think of this as a "Claim Ticket" a user gives you. It proves that the user just logged in and gave you permission.
console.log("authorization code :" + authorizationCode + " for client : " + client_id);
authorizationCodes.set(authorizationCode, {
client_id,
redirect_uri,
code_challenge
});
// Redirect back to client
const redirectUrl = `${redirect_uri}?code=${authorizationCode}`;
res.redirect(redirectUrl);
});
Side note: So here is a fun fact about the request to app.get("/authorize"). At first I thought we should use the *post method* here but turned out although standard APIs usually use POST for creating data, but the OAuth 2.0 specification (RFC 6749 section 3.1) actually requires the /authorize endpoint to support the GET method for multiple reasons (mainly because it's a redirect).
Step 3: The Code Delivery (Frontchannel)
Endpoint: GET http://localhost:3000/callback
The Request: The Auth Server redirects the user's browser back to the Client's callback URL, attaching the code in the URL.
Functionality: The Client App catches this code from the URL.
Security Note: At this point, the Client has the Code, but it doesn't have a Token yet.
As shown above, we've finished the front channel section of the UML diagram:
Step 4: The Token Exchange (Backchannel)
Endpoint: POST http://localhost:4000/token
The Request: The Client App sends a direct "Backchannel" POST request to the Server containing the code and the original code_verifier.
Functionality:
1. The Server retrieves the saved code_challenge from its Map.
2. It hashes the code_verifier sent by the client.
3. If Hash(verifier) === challenge, it proves the request is legitimate.
app.get("/callback", async (req, res) => {
const { code } = req.query;
if (!code) return res.send("No code received from Auth Server.");
try {
// 4. Exchange the Code for a Token
// We send the 'currentVerifier' that we saved earlier
const response = await axios.post(`${AUTH_SERVER_URL}/token`, {
grant_type: "authorization_code",
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: currentVerifier
}
// uncomment if you want to see the request using a proxy
// ,{
// proxy: {
// protocol: 'http',
// host: '127.0.0.1',
// port: 8080
// }}
);
const { access_token } = response.data;
Side note: Some of you might be wondering why we're sending different grant_type parameters (response_type, grant_type). Here's an explanation of the difference:
response_type: Tells the server what to send back to the user's browser (a "code" or a "token").grant_type: Tells the server what credentials the Client App is presenting to the private API (an "authorization_code", a "password", etc.).
Step 5: The Response (Backchannel)
Response: 200 OK { "access_token": "..." }
Functionality: The Server sends the access_token back to the Client.
Result: The Client App now has a valid token to make API requests, and the user is officially "logged in."
Here is the link to the full code: Oauth_lab
also there are some things that I didn't mention here (intentional vulnerabilities) which I highlighted in the repo