Signing In with a Provider
OAuth sends the user to a provider and gets a code back; state and an exact redirect URI are what keep it safe.
GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256"Sign in with Google" means you never see the password. Instead the user visits the provider, approves your app, and the provider sends the browser back to you with a short code. You swap that code for tokens on the server. This is the OAuth 2.0 authorization code flow.
The four steps
- Build an authorize URL with your
client_id, yourredirect_uri, thescopeyou want, and a randomstate. Redirect the browser to it. - The provider authenticates the user and redirects back to your
redirect_uriwithcodeandstate. - Check that
statematches the value you stored in the session. If it does not, drop the request — somebody else started this flow. POSTthe code to the provider's token endpoint with your client secret. You get an access token, and from OpenID providers anid_tokendescribing the user.
Exact matching, and PKCE
The provider compares redirect_uri against your registered one as a plain string. Nothing is normalised.
PKCE closes the gap if the code leaks. Generate a random code_verifier, send only its SHA-256 as code_challenge, then send the verifier when you exchange the code. A stolen code is useless without it.
Linking to your users
Key the account on provider plus sub, the provider's own user ID. Email addresses change hands.
Example
When to use it
- Adding "Sign in with Google" or "Sign in with GitHub" so you never store a password.
- Letting a corporate identity provider handle logins for an internal tool.
- Asking a user's permission to read their calendar or repositories on their behalf.
- Debugging a redirect_uri_mismatch error on a provider's developer console.
More examples
Starting the flow, with the shorthand for building the URL
The route behind the "Sign in with…" button. Keeping state and the verifier in the server-side session is what makes the check in the next step meaningful — a value the browser can edit proves nothing. The two URL builders are interchangeable; the object form is shorter, and the loop form is what you need when a parameter is conditional.
const { randomBytes, createHash } = require('node:crypto');
app.get('/auth/start', (req, res) => {
const state = randomBytes(32).toString('base64url');
const verifier = randomBytes(32).toString('base64url');
// Both go in the SESSION, not in a cookie a page script could read.
req.session.oauth = { state, verifier };
const params = {
response_type: 'code',
client_id: process.env.OAUTH_CLIENT_ID,
redirect_uri: process.env.OAUTH_REDIRECT_URI,
scope: 'openid email',
state,
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
code_challenge_method: 'S256',
};
// LONGHAND: one .set() call per parameter.
const url = new URL(process.env.OAUTH_AUTHORIZE_URL);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
// SHORTHAND: hand the whole object to URLSearchParams. Same string out,
// because URLSearchParams percent-encodes each value exactly the same way.
const same = `${process.env.OAUTH_AUTHORIZE_URL}?${new URLSearchParams(params)}`;
res.redirect(url.toString());
});The callback: check state, then exchange the code
The half that must run on the server, because it carries the client secret. Three things are easy to get wrong here: forgetting that redirect_uri is sent again at exchange and must be byte-identical, letting state be checked against something the browser supplied, and reusing the session ID afterwards instead of regenerating it.
app.get('/auth/callback', async (req, res) => {
const saved = req.session.oauth;
// Fail closed. A missing session or a mismatched state means this
// callback did not come from a flow this browser started.
if (!saved || req.query.state !== saved.state) {
return res.status(400).send('invalid state');
}
delete req.session.oauth; // single use
// fetch has been global since Node 18 and non-experimental since Node 21.
const tokenRes = await fetch(process.env.OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: process.env.OAUTH_REDIRECT_URI, // same string as step one
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET, // server side only
code_verifier: saved.verifier,
}),
});
if (!tokenRes.ok) return res.status(502).send('token exchange failed');
const tokens = await tokenRes.json();
// The callback must be async itself: await is a syntax error inside a
// plain function, even when the surrounding route handler is async.
req.session.regenerate(async (err) => {
if (err) return res.status(500).send('session error');
req.session.userId = await linkAccount(tokens.id_token);
res.redirect('/');
});
});Turning an id_token into one of your own users
The step people skip. An id_token arrives through the user's browser, so it has to be verified like any other input, against the provider's JWKS endpoint rather than a secret you hold. The unique constraint on (provider, sub) is the account-takeover guard: matching on email alone means whoever controls that address later controls the account.
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const jwks = jwksClient({ jwksUri: process.env.OAUTH_JWKS_URL });
async function linkAccount(idToken) {
// An id_token is a JWT signed by the PROVIDER. Verify it against their
// published keys; never trust jwt.decode on a token from outside.
const { kid } = jwt.decode(idToken, { complete: true }).header;
const key = (await jwks.getSigningKey(kid)).getPublicKey();
const claims = jwt.verify(idToken, key, {
algorithms: ['RS256'],
issuer: process.env.OAUTH_ISSUER,
audience: process.env.OAUTH_CLIENT_ID,
});
// Key on (provider, sub). sub never changes; an email address can be
// reassigned to a different person, which would hand over the account.
const { rows } = await pool.query(
`INSERT INTO users (provider, provider_sub, email)
VALUES ($1, $2, $3)
ON CONFLICT (provider, provider_sub) DO UPDATE SET email = EXCLUDED.email
RETURNING id`,
['example', claims.sub, claims.email],
);
return rows[0].id;
}
Discussion