Building OAuth From Scratch
To truly understand how that magic works, we need to look under the hood. We are going to implement a basic OAuth flow using nothing but standard Node.js and Express. No NextAuth, no better-auth, nothing.
The Problem: Why Does OAuth Even Exist?
Imagine you use a budgeting app called "BudgetBuddy." BudgetBuddy needs to see your bank transaction history to help you save money.
The Old (Bad) Way
In the early days of the internet, BudgetBuddy might have asked you for your bank username and password.
This is bad for a few reasons:
Security: You just gave a third-party app the keys to your entire bank account.
Trust: If BudgetBuddy gets hacked, your bank account gets hacked.
Control: You can’t limit access. BudgetBuddy has full access to transfer money, change settings, etc., just like you do.
The OAuth Way
This is why "Login with Google" (and OAuth) exists.
OAuth solves the problem of Delegated Authorization. It’s like a valet key for your car. You give the valet a special key that only starts the ignition (so they can park it), but it doesn't open the glove box or the trunk.
With OAuth we get two benefits
No Password Sharing: You never give your password to the app. You give it to the Provider (like Google).
Token-Based: The app gets a "token" that allows access to specific data for a limited time.
The Flow: How It Actually Works
The specific version of OAuth we see most often is called the Authorization Code Flow. It’s a bit of a dance between four main characters.
The Step-by-Step Flow
Here is what happens when you click that login button:
The Ask: You click "Login." The Client redirects you to the Authorization Server (Google).
The Permission: Google asks you: "Do you want to let this app see your email?" You say Yes.
The Code: Google redirects you back to your app with a temporary Authorization Code in the URL.
The Exchange: Your app takes that code and whispers to Google (server-to-server): "Hey, I have this code from the user. Can I have a real Access Token now?"
The Token: Google verifies the code and gives your app an Access Token.
The Data: Your app uses that token to fetch your data from the API.
Minimal Implementation (Node.js + Express)
Let's build this! We will assume we are connecting to a provider (GitHub or Google).
const express = require('express');
const axios = require('axios');
const app = express();
// we usually get these from your provider's developer console
const CLIENT_ID = 'your_client_id';
const CLIENT_SECRET = 'your_client_secret';
const REDIRECT_URI = 'http://localhost:3000/auth/callback';
const AUTH_URL = 'https://provider.com/oauth/authorize';
const TOKEN_URL = 'https://provider.com/oauth/token';
const USER_INFO_URL = 'https://api.provider.com/user';
Step 1: The Redirect
app.get('/auth/login', (req, res) => {
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'read:user'
});
res.redirect(`${AUTH_URL}?${params.toString()}`);
});
Lets breakdown it line by line
URLSearchParams: This is a handy utility to build query strings safely.client_id: Identifies our app to the provider.redirect_uri: Tells the provider, "Once the user says yes, send them back here."response_type: 'code': This is crucial. It tells the provider we are using the Authorization Code Flow. We want a code, not a token (yet).res.redirect: We physically move the user's browser from our site to the provider's site.
Step 2: The Callback
The user has clicked "Allow." The provider sends them back to our REDIRECT_URI with a surprise attached to the URL - http://localhost:3000/auth/callback?code=abc12345.
app.get('/auth/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Authorization code missing.');
}
try {
/
const tokenResponse = await exchangeCodeForToken(code);
const userData = await fetchUserProfile(tokenResponse.access_token);
res.send(`Hello, ${userData.name}! You are logged in.`);
} catch (error) {
res.status(500).send('Authentication failed');
}
});
req.query - In Express, this is how we read the URL parameters. We extract the
code.Error Handling - If there is no code, something went wrong (or the user said "No"), so we stop.
The Async Flow - We pause to exchange the code for a token, then use the token to get user data.
Step 3: The Exchange
This is the most critical security step. We take the "temporary code" and swap it for the "permanent token." This happens on the server side, so the user never sees it.
async function exchangeCodeForToken(code) {
const response = await axios.post(TOKEN_URL, {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: code,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code'
}, {
headers: { 'Accept': 'application/json' }
});
return response.data;
}
axios.post: We are sending a POST request to the provider.client_secret: This is our app's password. We never share this on the frontend. This proves to the provider that we are the ones asking for the token, not a hacker.grant_type: 'authorization_code': This tells the provider, "I am exchanging an auth code for a token."response.data: If successful, this object contains theaccess_token(and often arefresh_token).
Step 4: Using the Token
async function fetchUserProfile(accessToken) {
const response = await axios.get(USER_INFO_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
return response.data;
}
AuthorizationHeader - This is the standard way to present a token.Bearer- Think of this like saying, "I am the bearer of this valid ticket."response.data- This will finally return the json data we wanted (e.g.,{ "name": "John Doe", "email": "john@example.com" }) . Typical john doe.
Should you use this code in production?
Probably not. Libraries handle edge cases, security vulnerabilities, and token rotation much better than a raw implementation. But now, when you’ll use libraries like NextAuth, better auth, you’re not just copy pasting code without understnding.