DEV Community

Sanu Ranjan
Sanu Ranjan

Posted on AI-assisted

Understanding GitHub OAuth Login

Starting With Redirects

This is a beginner friendly post.

I am writing it because the GitHub OAuth login flow confused the hell out of me, and it took me a long time to work out why. The confusion was not about the code. The code is short, and you can copy it from anywhere.

What frustrated me was a much simpler question that no tutorial answered directly:

What request/response are being sent/received and who is sending and receiving them?

Every diagram I found showed arrows going back and forth between a browser, my backend, and GitHub. I assumed my backend was talking to GitHub the whole time. It is not. Most of the time it is the browser doing the talking, and my backend is just handing it addresses to go to.

And no, asking an AI did not shortcut it for me. It was frustrating and I had to dig a lot, because AI does not know what gaps we have in our knowledge and neither do we. The answers came back fluent and correct but I stayed just as confused as before.

So if you are a beginner, expect to dig. Even the most advanced model will hand you a tidy summary that reads like understanding but is not one, and it will keep doing that until you learn to ask a sharper questions.

Once that clicked, everything else fell into place. So we are going to go slowly, and we are going to start well before OAuth.


The knowledge gap (Prerequisites)

  • HTTP requests and responses. You should know that a response has a status code, a set of headers, and a body, and their purpose.

  • What is a redirect request?(covered in the blog)

  • Chrome DevTools, specifically the Network tab. You need to be able to open it, watch requests appear as you click around, click a single request, and read its Request Headers and Response Headers. You should also know where the "Preserve log" checkbox is, because without it the list wipes itself every time the page changes, and in this flow the page changes four times.

  • Writing requests using the fetch API.

  • Cookies, at least the basics. What are cookies, their basic working, what the Set-Cookie header in a response does, that the browser stores it and attaches it to later requests automatically, and roughly what HttpOnly means. The Application tab in DevTools is where you look at them.

  • What are cross site requests, and how the browser decides what counts as one and handles it.

  • Authentication and Authorization with JWT using Express.

  • And the code used


Part 1: What a redirect actually is

Before we touch OAuth, we need to be completely solid on one thing. Everything depends on it.

The normal case first

When our browser asks a server for a page, it gets back a response made of three parts: a status code, some headers, and a body.

HTTP/1.1 200 OK
Content-Type: text/html

<html><body>Hello</body></html>
Enter fullscreen mode Exit fullscreen mode

The status is 200, which means "here is what you asked for". The body is HTML. The browser paints that HTML on screen. The URL bar shows the address we requested. Nothing surprising.

Now the redirect

A redirect is a response with a status code in the 300s, usually 302, and no body at all.

HTTP/1.1 302 Found
Location: https://github.com/login/oauth/authorize?client_id=abc123
Enter fullscreen mode Exit fullscreen mode

That is the entire response. There is no HTML. There is nothing for the browser to paint.

What there is instead is a header called Location, and its value is an address.

Here is the part I had wrong

I assumed, for a long time, that the server sending this response then goes to that address itself. It does not. The server has finished the request response cycle with this response. It wrote a string into a header, set a status code, and closed the connection. It is done. It has no idea what happens next and it does not wait to find out.

The browser is the one that acts. It reads the Location value and sends a brand new GET request to that address, entirely on its own initiative.

Let us be precise about what "brand new request" means, because this is where the whole flow lives:

  • It is a separate request, with its own connection.
  • It gets its own row in the Network tab.
  • It carries whatever cookies belong to that new address.
  • It is indistinguishable from the user having typed that address into the URL bar by hand.
  • If that address also replies with a redirect, the browser does this entire dance again.
  • And the most important thing is that the browser sends the query parameters along when it requests that address.

Why you never see it happen

Because nothing was rendered. The 302 response had no body, so there was nothing to display, so the URL bar had no reason to update.

This is why a chain of two or three redirects looks like a single instant jump on screen. There were three separate requests. We just never saw the intermediate ones, because none of them produced a page.

The sentence to carry into the rest of this post: a redirect response from someone is telling the browser where to go next.

If you take nothing else from this article, take that.


Part 2: The goal, and the flow in one pass

Now that redirects make sense, let us look at what we are actually building.

The goal is to log a user into our app without our app ever seeing their GitHub password. They type that password on github.com and nowhere else. Our app never touches it, never sees it, so it cannot leak it.

Here is the whole flow before we go into any detail. Read it once to set a context, then we will take it apart piece by piece.

  1. The user clicks a "Log in with GitHub" button, which sends the browser to a route on our own backend.
  2. Our server responds with a redirect pointing at GitHub. The browser follows it.
  3. The user logs in on github.com and approves our app.
  4. GitHub responds with a redirect pointing back at our server. The browser follows that too. The address it points at carries a temporary code.
  5. Our server takes that code and calls GitHub directly, server to server, trading it for an access token.
  6. Our server uses that token to ask GitHub who this person is.
  7. Our backend saves the user, signs its own JWT, sets it in an HttpOnly cookie, and redirects to the dashboard.
  8. And now the user is logged in to our app.

Now count how many of those are our server talking to GitHub over the network.

Steps 5 and 6. That is it. Two requests.

Steps 1 through 4 are all browser navigations, exactly like the one we described in Part 1. Our backend and GitHub are just taking turns handing addresses along with query parameters to our browser through redirect requests.


Part 3: The button, and why it points at our own server

Our login button is an anchor tag:

<a href="http://localhost:5000/api/auth/github">Log in with GitHub</a>
Enter fullscreen mode Exit fullscreen mode

That one line has two decisions baked into it, and I got both of them wrong at first. Let us take them one at a time.

Question one: why does it point at our own Express server?

The obvious thing to do is put GitHub's authorize URL straight into that href. And it would technically work. So why bounce off our own backend first?

The answer is a query parameter called state. We will explain why in Part 7. For now, all we need is that the state must be a fresh random value on every single login attempt, and it must be stored in an HttpOnly cookie.

Hold those two constraints next to the idea of hardcoding the URL in our href.

An href written into JSX is a fixed string. It is baked in when the app is built. Every user, on every attempt, from now until we redeploy, would send the exact same state. A value that never changes is not random, so it defeats the purpose.

"Fine," I thought, "I will generate it in the browser with JavaScript."

But frontend JavaScript cannot set an HttpOnly cookie. That is the entire purpose of the HttpOnly flag. It exists specifically to keep JavaScript away from the value. So we would have to store our state somewhere JavaScript can reach, like localStorage, which means any cross site scripting bug in our app can read it and the protection is gone again.

Let's look at what actually needs to happen:

  1. Generate a random value.
  2. Store it in an HttpOnly cookie.
  3. Send the redirect response taking the user to GitHub's login/consent page.

Those three things have to happen together, in one response. Only a server can do all three. The browser can do the third one, but not the second.

That is why the first stop is our own route, and GitHub comes one step later.

Our client_id, scope and redirect_uri differ between development and production, and on the server they come from environment variables. Keeping the URL construction on the backend means our frontend knows nothing about GitHub at all. That matters a lot the day we add Google login next to it, because nothing in the frontend has to change.

Question two: as the href holds the backend, why an anchor tag and not a GET request using the fetch API?

Because our route is going to respond with a redirect, and we need the browser itself to act on it.

An anchor tag hands control to the browser, and the browser makes the GET request and handles the redirect response received. That is what makes the rest of the chain happen at all.


Part 4: What our login route does

Three things, in a single response.

app.get("/api/auth/github", (req, res) => {
  const state = crypto.randomBytes(32).toString("hex");

  res.cookie("oauth_state", state, {
    httpOnly: true,
    sameSite: "lax",
    maxAge: 10 * 60 * 1000,
    path: "/",
  });

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID,
    redirect_uri: "http://localhost:5000/api/auth/github/callback",
    scope: "user:email",
    state,
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
Enter fullscreen mode Exit fullscreen mode

Read res.redirect() carefully, because the name invites the wrong mental model.

It is not a function that reaches out to GitHub. It does not open a connection. It does not wait for anything. All it does is set a 302 status and a Location header on our response, and end it. Whether anything happens after that is entirely the browser's business.

Two things to notice in the params.

client_id is public. It sits in a URL. That is fine and by design.

client_secret does not appear here at all. It never goes near the browser. We will use it exactly once, in Part 8, from our server.


Part 5: The user is on GitHub now

The browser follows the redirect and lands on github.com.

Before showing anything, GitHub checks two things. That our client_id exists, and that the redirect_uri we sent is an exact string match for the one registered in our OAuth app settings. Not a close match but exact. One trailing slash difference and we get an error page instead of a login screen.

Then the user types their password.

This happens on github.com, on a page GitHub served, over GitHub's own TLS connection. Our app has no visibility into any part of it. There is no way for us to read that password even if we wanted to, because it is never sent anywhere near us.

That is the entire security promise of OAuth, and it is worth pausing on. The reason this whole convoluted redirect dance exists is to get that one property.

After the password, GitHub shows the consent screen listing the scopes we asked for. The user approves.

Consent only appears the first time. GitHub records that this user authorized this app for these scopes. On every later login it skips the screen entirely and redirects straight back, which is why the second login feels fast.

It reappears in two cases. If we add a scope the user has not approved, or if the user revokes our app at github.com/settings/applications. That second one is worth doing once during development just to see the screen again.


Part 6: The "callback URL" is a misleading name

This was the single biggest source of my confusion, so I want to be blunt about it.

GitHub does not call our callback URL. It never opens a connection to our server. Not once, at any point in the entire flow.

What GitHub actually does is send another 302 back to the browser:

HTTP/1.1 302 Found
Location: http://localhost:5000/api/auth/github/callback?code=abc123&state=xyz789
Enter fullscreen mode Exit fullscreen mode

And then it hangs up. Same as Part 1. It set a status, wrote a header, and finished.

The browser reads that Location value and sends a fresh GET request to our server, carrying the code in the query string. The browser is the middle man.

So "callback URL" is the address we want the browser sent back to with the code in the query string.


Part 7: The state check, and a SameSite trap

Our callback route runs. The first thing it does is compare the state in the query string against the oauth_state cookie, and then clear that cookie.

Why?

Here is the attack it prevents. Follow it slowly, because it is easy to misread which account is which.

An attacker goes to GitHub and authorizes our app using his own GitHub account. He gets back a valid code. It is a real code, correctly issued, and it points at his account.

He does not use it.

Instead, he gets our user to load this address, maybe through a link in an email or an image tag on a page:

http://ourapp.com/api/auth/github/callback?code=HIS_CODE
Enter fullscreen mode Exit fullscreen mode

Our server sees a code, redeems it, and logs that user's browser into the attacker's account.

Wait, why would he use his own account?

This is the part that confused me, and it is worth stopping on, because the attack looks backwards at first.

He is not trying to break into the victim's GitHub. He cannot. The code he planted was issued for his account, so our server reads his email, his profile, his numeric id, and logs the browser into his user record. The victim's GitHub is never touched at any point, and the user has no clue because there was no consent screen.

What he gets is everything the victim does afterwards.

The victim clicks the link and our app looks completely normal. They are logged in, nothing errors, and almost nobody checks which account they are in. So they carry on and use the app. They write notes, upload files, save a delivery address, connect something at checkout. All of it gets written against the attacker's user record.

Then he logs into our app from his own machine, normally, with his own GitHub, and reads it.

So the value of this attack depends entirely on what our app stores. On a login only app that holds nothing, it is close to worthless. On anything where users create, upload or save things, it is a real problem. The account is a container, and he has quietly handed the victim one that he owns.

How state stops it

The state cookie shuts this down cleanly. The attacker cannot read or set an HttpOnly cookie in someone else's browser. So his forged request arrives with a state that does not match whatever is in the victim's cookie, and our server rejects it before ever touching the code.

The SameSite trap

This one is confusing as hell.

Our oauth_state cookie belongs to our own domain. So I expected it to be sent to our own server without question. That is not how SameSite works.

SameSite is not about who owns the cookie. It is about which page the navigation started on.

Look back at Part 6. That redirect originates on github.com. So from the browser's point of view this is a cross site navigation, even though the destination is ours and the cookie is ours.

With SameSite=Strict, the browser withholds the cookie. Our state check then fails on every single login, and the error message will not tell us why, because from our code's perspective the cookie simply is not there.

SameSite=Lax is the minimum that works, because Lax permits cookies on top level navigations like this one.

We want Lax on our own session JWT too, for two separate reasons. First, the page load after login is the tail end of a chain that started on github.com. Second, if a user clicks a link to our site from Gmail or WhatsApp, Strict would land them on our page logged out, which looks like a bug to them.


Part 8: Trading the code for a token

This is the first time in the entire flow that our server talks to GitHub over the network. No browser is involved. Nothing here is visible to the user.

const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json",
  },
  body: JSON.stringify({
    client_id: process.env.GITHUB_CLIENT_ID,
    client_secret: process.env.GITHUB_CLIENT_SECRET,
    code,
    redirect_uri: "http://localhost:5000/api/auth/github/callback",
  }),
});
Enter fullscreen mode Exit fullscreen mode

Two things here will waste an hour if nobody warns you.

Send Accept: application/json. Without it, GitHub replies with a form encoded string that looks like access_token=gho_xxx&scope=&token_type=bearer. Your tokenRes.json() will throw, and the error will not point at the missing header.

A bad code returns HTTP 200. Not 400. Not 401. You get a 200 OK with an error object sitting in the body. So if (!tokenRes.ok) will happily let a failure through. You have to check whether access_token is actually present in the parsed response.

Why is it safe for the code to travel through the URL?

This bothered me for a while. In Part 6 that code sat in the URL in plain text. Which means it went into browser history.

It is fine, because the code is single use and short lived.

The moment we exchange it, GitHub marks it consumed. Send the same code again and we get an error. And it expires on its own in about ten minutes whether we use it or not.

So anyone who digs that string out is holding something that has either already been spent or is about to expire on its own. The reusable, valuable thing is the access token we just received, and that never leaves our server.


Part 9: Finding out who they are

Here is a thing that is easy to assume and wrong: the access token does not tell us who the user is.

It is a key, not an ID card. It is proof that we are allowed to ask questions about the user logging in.

const headers = {
  Authorization: `Bearer ${accessToken}`,
  "User-Agent": "your-app-name",
  Accept: "application/vnd.github+json",
};
Enter fullscreen mode Exit fullscreen mode

That User-Agent header is not optional. GitHub's API returns 403 Forbidden without it, and the error message does not obviously point at the cause. Every GitHub API request needs it.

Now we make our second and last server to server call.

https://api.github.com/user gives us the profile: id, login, name, avatar_url.

But email will very often come back as null, which is not a bug. Most people keep their email private on GitHub, and that endpoint respects the setting.

So we call https://api.github.com/user/emails as well. That returns an array of every email on the account, and we want the one where both primary and verified are true.

The verified part is not optional either. If the primary email is not verified, we treat the account as unverified rather than trusting the address. Otherwise someone could add an email they do not own to their GitHub account, log into our app, and get matched to an existing user in our database who does own it.


Part 10: Saving them, and our own session

Now that we have our user details, we set our own JWT using them as the payload and issue that to the client after login.


The short version

If we remember one thing from all of this, it is this:

Almost every step in OAuth is a server writing an address into a Location header and hanging up, while the browser does the walking.

So here is the exercise that taught me more than every diagram I looked at. Open DevTools. Go to the Network tab. Tick "Preserve log" so the rows survive all the page changes. Clear the list. Then run a login.

Go through them one at a time and find out, who sent this request, our server or the browser.

P.S. I wrote this while learning it, which means there is a decent chance something here is wrong or oversimplified. If you know OAuth properly and spot a mistake, please comment. Corrections are welcomed as the post gets better for the next beginner who lands on it.

Top comments (0)