Understanding OAuth2 the Simple Way
You are building a side project. It needs to let users save their data, but you really do not want to manage passwords, salt hashes, password resets, or brute-force rate limiting. So you slap a "Sign in with Google" button on your frontend.
Suddenly, a user clicks it, a popup appears, Google asks if they trust your app, and when they say yes, your backend somehow knows who they are without ever seeing their Google password.
That magic trick is OAuth2. And if you read the official RFC specifications first, you will quickly wish you had chosen a simpler hobby like woodworking. The spec reads like a legal document written by enterprise architects who hate you.
Let's strip away the enterprise jargon and look at what is actually happening.
The Valet Key Analogy
Forget tokens, scopes, and grant types for a second. Think about a valet key for a car.
When you hand your car to a hotel valet, you don't give them your master house key, your wallet, and permanent ownership of the vehicle. You give them a special, restricted key that only starts the engine and moves the car a short distance. If they try to open the glove box or the trunk, that key doesn't work. And when you get back, you take the key back.
OAuth2 is just a digital valet key.
Instead of asking users for their username and password—which requires them to blindly trust you won't get hacked and leak their credentials—your application asks an authorization server (like Google, GitHub, or Auth0) for a temporary badge. That badge says: "Hey, user X has allowed this app to read their basic profile, but nothing else."
Your app uses that badge (the access token) to fetch data, and the identity provider handles the scary part of verifying who the user actually is.
The Authorization Code Flow (The One You'll Actually Use)
There are several ways to do OAuth2, known in the spec as "grant types." Ignore the implicit grant (it's dead and insecure anyway), ignore the password grant, and just learn the Authorization Code Flow. It's the standard for web apps.
Here is the exact sequence of events when a user clicks "Log in with GitHub":
- The Redirect: Your app sends the user's browser to GitHub with a URL parameter saying, "Hey, I'm app X, and I need read access to user profiles."
- The Consent: GitHub shows the user a scary permission screen. The user clicks "Authorize."
-
The Callback: GitHub redirects the user back to your server (
/auth/callback) with a temporary, single-use code in the URL query string. This is not the access token. It's just a receipt. - The Exchange: Your backend takes that temporary code and makes a server-to-server HTTP POST request directly to GitHub, trading the code for the actual Access Token. Because this happens on your backend, your client secret stays hidden from the browser.
Here is what step 4 looks like in Node.js using Express and axios. This assumes you've already received the code query parameter from the callback.
const express = require('express');
const axios = require('axios');
const app = express();
app.get('/auth/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('No code provided from provider.');
}
try {
// Step 4: Exchange the temporary code for an access token
const tokenResponse = await axios.post('https://github.com/login/oauth/access_token', {
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code: code
}, {
headers: {
accept: 'json' // GitHub is quirky, it returns query params by default unless you ask for JSON
}
});
const accessToken = tokenResponse.data.access_token;
// Now use the access token to fetch the user's profile
const userResponse = await axios.get('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
console.log("Logged in user:", userResponse.data.login);
res.send(`Welcome, ${userResponse.data.login}!`);
} catch (err) {
console.error('OAuth failed:', err.response?.data || err.message);
res.status(500).send('Authentication failed.');
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
The Gotchas (Or: What Tripped Me Up)
When I first built this, I made a few assumptions that blew up in my face. Save yourself the debugging hours.
1. Confusing Authentication with Authorization
OAuth2 is technically an authorization framework, not an authentication framework. It answers the question: "Is this app allowed to read this user's data?" It doesn't strictly guarantee who the user is.
Because of this, people started abusing OAuth2 to handle logins. To fix that, the industry created OpenID Connect (OIDC), which sits on top of OAuth2 and standardizes identity tokens. If you are using Google or Auth0, you are usually using OIDC under the hood of your OAuth flow. Don't sweat the distinction too much early on, but know why people keep throwing terms like JWT and OIDC into the conversation.
2. State Parameter CSRF
Look at the code snippet above. Notice I didn't include the state parameter? That was lazy and insecure.
An attacker could theoretically intercept your user's auth flow and inject their own authorization code, logging the victim into the attacker's account. To prevent this, you should generate a random cryptographic string (state), send it to the provider, and verify that the state coming back in the callback matches what you stored in the user's session. If it doesn't match, drop the request.
3. Token Expiration
Access tokens expire. Sometimes in an hour, sometimes in days. If your app assumes an access token lasts forever, your users are going to experience random mysterious logouts. You either need to gracefully prompt them to re-authenticate or implement Refresh Tokens, which let your server ask for a new access token without bothering the user again.
Making API Requests with the Token
Once you have that access token safely stored (in an HTTP-only cookie or a secure session, never in localStorage if you want to avoid XSS nightmares), making requests on behalf of the user is straightforward.
You just attach it to the Authorization header. Here is a quick example of fetching a user's repositories from GitHub using that token:
async function getUserRepos(accessToken) {
try {
const response = await axios.get('https://api.github.com/user/repos', {
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'My-Awesome-App' // GitHub requires a User-Agent header or it blocks you
},
params: {
visibility: 'private',
sort: 'updated'
}
});
return response.data;
} catch (error) {
if (error.response && error.response.status === 401) {
console.log('Token expired or revoked. Time to refresh or re-auth.');
}
throw error;
}
}
Notice how clean that is once the initial setup is done. Your app doesn't know or care about passwords; it just passes the token along like a VIP wristband at a concert.
Next Steps
Stop reading theory. Open up GitHub, Google Cloud Console, or Auth0, and create a dummy developer application. Write a tiny local Express or Flask server, wire up the redirect and callback routes shown above, and log your own username to the console. Seeing the data come back live makes the whole loop click into place faster than reading ten more articles.
Top comments (0)