OAuth and Social Login
You copy twelve lines from a blog post, paste in a client ID and a secret, and the “Sign in with Google” button works on the first try. Ship it. A week later a security review asks one question: why does the token exchange happen on your server instead of in the browser? And you realize you have no answer. You copied a dance without ever learning the steps.
This is the most cargo-culted protocol on the web. People wire it up correctly by luck, then get one detail wrong on the next project and hand an attacker a way into other people’s accounts. The good news is that the whole thing rests on two ideas, and once they click, the rest is just plumbing you can read off a diagram.
Authorization and authentication are not the same word
Start here, because mixing these two up is the root of most of the confusion.
OAuth answers “is this app allowed to do this on your behalf?” That is authorization. It was invented so you could let a photo-printing service pull images from your cloud drive without handing over your cloud password. You delegate a thin slice of access. The printer never sees your password and can only touch the photos you approved, nothing else in the account.
Authentication answers a different question: “who are you?” For years developers bolted identity onto OAuth by hand, each provider a little differently, and it was a swamp. OpenID Connect (OIDC) is the standard that drained it. OIDC is a thin layer on top of OAuth that adds exactly one thing: a signed token describing who just logged in, the id_token. When you click “Sign in with Google,” that button is OIDC. The OAuth machinery underneath is doing the delegation; the OIDC layer on top hands your app a verified “this is Alice.”
Keep the split in your head: OAuth gets you a key to an API, OIDC gets you a statement of identity. Most social login is OIDC riding on OAuth, and, crucially, the flow on the wire is nearly identical either way. Learn it once.
The four roles
Before the steps, the cast. OAuth defines four roles, and every tutorial that skips naming them is why you find this confusing.
- Resource owner is you, the human who owns the data.
- Client is the app that wants access. Your web server, the photo printer, the mobile app.
- Authorization server is the thing that authenticates the owner and issues tokens once they approve. Google’s accounts server.
- Resource server holds the protected data or API and honors the token. The Google Photos API.
In OIDC dialect the authorization server is called the OpenID Provider and your app is the Relying Party. Same boxes, fancier names.
The mental model that makes it stick is a valet key. Some cars ship with a restricted key you hand the valet: it starts the engine and opens the door, but it will not open the trunk or the glovebox and it caps the speed. A token is a valet key for your data. The front desk (authorization server) mints it, it is scoped to a few actions, you give it to someone you do not fully trust (the client), and the car (resource server) honors it only within its limits.
The one flow you should use: authorization code with PKCE
There is essentially one flow worth learning in 2026, and it is called authorization code with PKCE. Everything else is either this, or a retired mistake we will get to. Walk it once, slowly. The numbers on the diagram match the paragraphs below it.
Here is what each step is actually doing.
Steps 1 and 2. The user clicks your button. Your server builds an authorize URL and sends a redirect. That URL is where you declare what you want:
const params = new URLSearchParams({
response_type: "code",
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: "https://app.example.com/auth/callback",
scope: "openid email profile",
state, // random, tied to this browser
code_challenge: challenge, // the PKCE challenge, explained below
code_challenge_method: "S256",
nonce, // random, echoed inside the id_token
});
const authorizeUrl =
"https://accounts.google.com/o/oauth2/v2/auth?" + params.toString();
You are building a query string, nothing more (see the URL API if URLSearchParams is new). Notice what is not here: no secret, no password. This half of the flow is public by design.
Steps 3 and 4. The browser lands on the provider. The user logs in to the provider, not to you, and approves the scopes on a consent screen. Then the provider redirects the browser back to your redirect_uri with a short, one-time authorization code stuck in the query string.
Step 5. The browser hits your callback and hands you the code:
app.get("/auth/callback", async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(400).send("bad state"); // more on this below
}
// ...exchange the code, next step
});
Steps 6 and 7. Now the important part. Your server, not the browser, makes a direct call to the provider’s token endpoint. This request carries your client_secret and the PKCE code_verifier, and it never touches the user’s browser:
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://app.example.com/auth/callback",
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET, // back channel only
code_verifier: verifier, // proves you started it
}),
});
const tokens = await res.json();
Step 8. The provider returns tokens. You validate the id_token, learn who the user is, and create your own session. We will spend real time on that last step, because it is the one everybody forgets.
Why PKCE exists
PKCE (Proof Key for Code Exchange, pronounced “pixie”) is the part people paste without understanding, so let us actually understand it.
Split clients into two kinds. A confidential client is a server-side app that can keep a secret in an environment variable the user never sees. A public client cannot keep any secret at all: a single-page app ships its JavaScript to every visitor, and a mobile app is a binary anyone can unzip. Bake a secret into either and it is not a secret.
So imagine a public client with no secret. What stops an attacker who intercepts the authorization code from just redeeming it themselves? On mobile this was a real, exploited attack: a malicious app registers for the same custom URL scheme as a legit app, the operating system hands it the redirect, and it walks off with the code. Without a secret to prove “I am the app that started this,” nothing stopped the exchange.
PKCE closes the gap with a proof you generate on the fly:
- Before you redirect, generate a high-entropy random string, the
code_verifier. - Hash it with SHA-256 into a
code_challenge. - Send only the challenge in the front-channel authorize request (step 2). The verifier stays on your side.
- At the token exchange (step 6), present the original verifier. The provider hashes it and checks the result equals the challenge it stored alongside the code.
An attacker who steals the code from the browser never saw the verifier. They cannot reverse a SHA-256 hash to recover it. So they cannot complete the exchange, and the stolen code is inert.
The verifier is 43 to 128 unreserved characters. In practice you generate 32 random bytes and base64url-encode them, which lands at 43 characters with plenty of entropy. The challenge is BASE64URL(SHA256(verifier)), and the method is S256. There is a legacy plain method where the challenge equals the verifier, but it defeats the point; always use S256.
Play with it below. Start a flow to generate a fresh verifier and its challenge, then try to complete the exchange two ways: as yourself with the real verifier, and as an attacker who grabbed the code but never saw the verifier. The “provider” here re-hashes whatever verifier it is given and compares, exactly like the real one, all in your browser with Web Crypto and no network.
OAuth 2.1 makes PKCE mandatory for every client, confidential ones included. Code interception is not only a public-client problem, and the check is nearly free, so it is now the default everywhere. If your provider or library still treats it as optional, turn it on anyway.
The state parameter, and CSRF on the callback
state is one line of code and skipping it is a real hole, so it gets its own section.
Generate a random state value, store it tied to the user’s browser (in their session, or a signed cookie), and include it in the authorize request. The provider echoes it back on the callback. Before you do anything with the code, check the returned state matches what you stored.
Miss this and you have a login CSRF bug. The attack: an attacker starts the flow with their own provider account, grabs the callback URL (which now contains a code for the attacker’s identity), and tricks a logged-in victim’s browser into visiting it. Your callback dutifully exchanges the code and links the attacker’s provider identity into the victim’s session. Now the victim is quietly operating an account the attacker also controls, typing in private data the attacker can later read. state binds the callback to the same browser that began the flow, so a callback the victim never started gets rejected.
Scopes: ask for less
Scopes are the permissions you request, a space-separated list like openid email profile or drive.readonly or repo. They are exactly what the user sees on the consent screen.
Ask for the minimum, for two reasons. Users bail on a consent screen that demands their whole account to log in, so every extra scope costs you conversions. And every scope you hold is something you can leak. For plain social login, openid email profile is usually all you need. Request anything heavier only when the feature that needs it is actually in front of the user, and request it then (this is incremental consent). Asking for repo access on your login button because you might build a GitHub integration next quarter is how you end up the subject of a breach writeup.
What comes back, and what to keep
The token endpoint returns JSON. A full response for a login looks like this:
{
"access_token": "ya29.a0Af...",
"expires_in": 3599,
"refresh_token": "1//09Fx...",
"scope": "openid email profile",
"token_type": "Bearer",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
}
The id_token is a JWT. Validate its signature against the provider’s public keys, check iss, aud, exp, and your nonce, then decode the payload:
{
"iss": "https://accounts.google.com",
"aud": "your-client-id.apps.googleusercontent.com",
"sub": "108117684590590418559",
"email": "[email protected]",
"email_verified": true,
"name": "Alice Rivera",
"iat": 1752800000,
"exp": 1752803600,
"nonce": "n-8fPq2..."
}
Now decide what to keep:
subis the stable, unique id for this user at this provider. This is the key you match on, forever. People change their email;subdoes not move.id_tokenyou usually read once and discard. It proved the login. Pull outsubandemailand you are done with it.access_tokenyou keep only if you will call the provider’s API later (syncing a calendar, reading repos). If you only wanted login, throw it away. If you keep it, encrypt it at rest.refresh_tokenyou keep only for long-lived API access, encrypted, treated like a password. More on rotating it below.
One rule that saves you real pain: do not treat email as identity across providers by itself. That is the next section.
The account-linking problem you just created
The moment you support more than one provider, you own a matching problem. Same human logs in with Google today and GitHub next month, same email address on both. Or, nastier, the same email address belongs to two different humans over time, or to an attacker who set it as an unverified alias.
If you key accounts on email, two things go wrong. The mild version: Alice signs in with Google, later with GitHub, and you create two separate accounts for one person, who is now confused about where her data went. The dangerous version: an attacker creates a GitHub account with an unverified email matching a victim’s, signs into your app, and your email-based matching drops them straight into the victim’s account.
The shape that works:
-- one row per human
CREATE TABLE users (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
email TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- many provider identities per human
CREATE TABLE oauth_identities (
provider TEXT NOT NULL, -- 'google', 'github'
provider_user_id TEXT NOT NULL, -- the provider's stable 'sub'
user_id BIGINT NOT NULL REFERENCES users(id),
PRIMARY KEY (provider, provider_user_id)
);
On every login you look up by the provider plus its id, never the email:
SELECT user_id FROM oauth_identities
WHERE provider = $1 AND provider_user_id = $2;
Three rules keep you safe. Match on (provider, provider_user_id). Only auto-link two identities by email when the provider asserts email_verified: true and you actually trust that provider’s verification; otherwise force an explicit “link this account” step while the user is already logged in. And keep one users row per human with many oauth_identities rows hanging off it. (These are the identity tables that sit next to your sessions and password hashes.)
Redirect URI: exact match or bust
The redirect_uri is where the provider sends the code, which makes it a prime target. If an attacker can make the code land at a URL they control, they have the code.
Two defenses stack here. You pre-register your exact redirect URIs with the provider, and OAuth 2.1 requires the provider to compare them with exact string matching. No wildcards, no “starts with,” no substring. https://app.example.com/auth/callback matches only itself. It does not match https://app.example.com.evil.com/auth/callback, and it does not match https://app.example.com/auth/callback/anything.
This is also where an ordinary open-redirect bug on your own domain turns into token theft. If some page on your site bounces users to a URL from a query parameter, and your redirect matching is loose, an attacker can chain the two to steer a code off your domain. Register the exact path, keep the list tight, and never reflect a user-supplied redirect target into the flow.
Refresh tokens and rotation
Access tokens are deliberately short-lived, often under an hour. When one expires you do not drag the user back through the whole dance. You POST the refresh token to the token endpoint and get a fresh access token:
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: stored, // treat this like a password
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
}),
});
// the response may include a NEW refresh_token: store it, drop the old one
Refresh tokens are long-lived, which makes them juicy. OAuth 2.1’s rule for public clients is that refresh tokens must be either sender-constrained or one-time-use with rotation. Rotation means every refresh hands back a new refresh token and invalidates the old one. That gives you theft detection nearly for free: if a stolen token gets used, the legitimate client’s next refresh fails because its token was already spent, the provider sees a reused token, and it can revoke the whole chain. Store refresh tokens encrypted, rotate them, and revoke on logout.
After the dance, the session is yours
Here is the step the twelve-line snippet skips, and it is the most important one.
When the flow finishes, you are not “logged in with Google” in any ongoing way. What you have is a single moment where a provider vouched for who this person is. What you do with that moment is create your own session, exactly as you would after a password login. Set your own cookie, keep your own session record, and from the next request onward the user is authenticated by you, not by Google.
This is why “should I store the id_token in localStorage” is the wrong question with the wrong answer. You do not keep the id_token around as a credential. You read it once, mint your session, and let your normal machinery take over. Which machinery, and which flags, is a decision you have already made elsewhere: see sessions vs JWT for the session model and cookies done right for the HttpOnly, Secure, and SameSite flags that keep the cookie from being stolen. OAuth is the doorman who checks ID at the entrance. Your session is the wristband that gets the user around for the rest of the night.
Build the client, do not build the server
A closing opinion you can act on.
Writing an OAuth client, the relying-party side that runs the dance above, is a reasonable thing to do by hand. It is a few hundred lines, it is genuinely educational, and you now understand every step of it. In production I would still reach for a maintained library, because it removes a whole category of “I forgot to check state” bugs. In the Node world, openid-client is a solid, spec-compliant choice that handles PKCE, state, nonce, and provider discovery for you.
Writing an OAuth authorization server, the thing that issues tokens, is a different animal entirely. Token lifecycle, PKCE verification, consent, key rotation, revocation, every RFC and every edge case: this is a security product, and getting it subtly wrong is a breach with your name on it. Do not roll your own. Use something built for it.
Summary
- OAuth is authorization (an app may act on your behalf); OIDC is authentication bolted on top (who you are, via a signed
id_token). “Sign in with Google” is OIDC riding on OAuth. - The id_token is for your app to read identity from; the access_token is for calling an API. Do not swap them.
- The four roles: resource owner (you), client (the app), authorization server (issues tokens), resource server (honors them). The token is a scoped valet key.
- Use authorization code with PKCE and nothing else. The implicit flow is dead, removed from OAuth 2.1 along with the password grant.
- The code travels through the browser (front channel); the token exchange is server-to-server (back channel) with your
client_secret. That split is the whole security design. - PKCE proves you started the flow: hash a random
code_verifierinto acode_challengeup front, present the verifier at exchange. A stolen code without the verifier is inert. It is mandatory for all clients now. - Always send and check
state(CSRF on the callback) and, for OIDC,nonce(id_token replay). - Ask for the least scope that does the job, and only when the feature needs it.
- Match users on
(provider, sub), not email. Oneusersrow per human, manyoauth_identitiesrows. Auto-link by email only when it is verified and trusted. - Redirect URIs are matched exactly. No wildcards. A loose match plus an open redirect equals stolen tokens.
- Rotate refresh tokens, store them encrypted, revoke on logout.
- After the dance you issue your own session in your own cookie. The provider’s job ends the moment you know who the user is.
- Roll your own client if you like; never roll your own server. Reach for Auth.js, Clerk, WorkOS, or a library like
openid-client.