The "Sign in with X" quickstart guides are optimized to get you to a working demo in 15 minutes. They're not optimized to tell you about the CSRF vector you've just opened, why you should never trust an email address without checking email_verified, or how Apple's privacy relay creates a permanent account linking problem. This post covers the gaps.
The state parameter: CSRF protection that most demos skip
The OAuth authorization flow sends users to an external provider and waits for them to come back. An attacker can craft a URL that initiates this flow and trick a victim into visiting it, then capture the callback — a login CSRF attack. The state parameter prevents this.
When initiating the authorization request, generate a cryptographically random value, store it server-side (in the session or a short-lived database record), and include it in the authorization URL. When the callback arrives, verify the state matches before doing anything else.
import crypto from 'crypto';
// When the user clicks "Sign in with Google"
export async function initiateOAuth(req: Request, res: Response) {
const state = crypto.randomBytes(32).toString('hex');
const nonce = crypto.randomBytes(32).toString('hex');
// Store state + nonce in a short-lived session (5 minutes)
await redis.setex(`oauth_state:${state}`, 300, JSON.stringify({
nonce,
returnTo: req.query.returnTo || '/',
createdAt: Date.now(),
}));
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: 'https://app.example.com/auth/google/callback',
response_type: 'code',
scope: 'openid email profile',
state,
nonce, // included in the id_token for replay protection
access_type: 'offline', // request refresh token
prompt: 'select_account',
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
}
// When Google redirects back
export async function handleCallback(req: Request, res: Response) {
const { code, state, error } = req.query;
if (error) {
return res.redirect('/login?error=oauth_denied');
}
// Verify state BEFORE doing anything else
const storedData = await redis.get(`oauth_state:${state}`);
if (!storedData) {
return res.status(400).send('Invalid state parameter');
}
// Delete it immediately — single use
await redis.del(`oauth_state:${state}`);
const { nonce, returnTo } = JSON.parse(storedData);
// Exchange code for tokens
const tokens = await exchangeCode(code as string);
const idToken = verifyAndDecodeIdToken(tokens.id_token, nonce);
// ... create session
}
The nonce: OIDC replay protection
The nonce is separate from state and serves a different purpose. state protects the authorization request from CSRF. The nonce is embedded in the id_token by the provider and protects the token from replay attacks — an attacker who intercepts a valid id_token cannot reuse it because your server checks that the nonce in the token matches the one you generated.
When verifying the id_token, always check the nonce claim:
import { OAuth2Client } from 'google-auth-library';
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
async function verifyAndDecodeIdToken(idToken: string, expectedNonce: string) {
const ticket = await client.verifyIdToken({
idToken,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload()!;
// Verify nonce
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch — possible replay attack');
}
// Verify email is confirmed
if (!payload.email_verified) {
throw new Error('Email not verified by provider');
}
return payload;
}
id_token vs access_token: what each is for
OIDC flows return two tokens and most developers conflate them. The id_token is for your application: it contains claims about the user (sub, email, name, picture) and is signed by the provider's private key. Verify it with the provider's public keys from their JWKS endpoint, extract the claims, and use them to create or update your user record.
The access_token is for calling the provider's APIs: use it to call Google's People API, GitHub's API, etc. Do not decode it expecting user claims — it may not be a JWT at all (GitHub returns an opaque access token). Never send an OIDC access token to your own backend as authentication — it's scoped for Google/GitHub, not for you.
The email_verified edge case
GitHub does not include an email_verified claim in its OIDC responses. Google does, and it can be false for older accounts that haven't completed email verification. If you automatically trust the email from a social login without checking email_verified, an attacker can register a Google account with any email address (without verifying it), then use it to log in to your application as that email's owner.
function extractVerifiedEmail(provider: string, claims: Record<string, any>): string {
const email = claims.email;
if (!email) throw new Error('No email in claims');
// GitHub doesn't include email_verified but only returns
// confirmed primary emails via the /user/emails endpoint
if (provider === 'github') {
// Email from /user endpoint is always primary + verified
return email;
}
// For OIDC providers (Google, Apple), check the claim explicitly
if (claims.email_verified === false) {
throw new EmailNotVerifiedError(email);
}
return email;
}
When you get an EmailNotVerifiedError, don't silently fail. Show the user a message: "Your Google account email hasn't been verified. Please verify it in Google's account settings and try again."
Apple's private relay email
Sign in with Apple has a feature called Hide My Email that generates a random relay address (e.g. abc123@privaterelay.appleid.com) instead of revealing the user's real email. This relay forwards emails from your app to the user's actual inbox.
The problem: the relay address changes per-app and per-user, but the user's sub claim is stable. Always match Apple users by sub, never by email. Apple also only returns name and email claims on the first sign-in — store them immediately or you'll never see them again.
async function handleAppleCallback(idToken: string, formData: any) {
const claims = await verifyAppleIdToken(idToken);
// Apple's sub is stable — use it as the primary identifier
const appleUserId = claims.sub;
// Name is only in the POST form body on FIRST sign-in, never in subsequent tokens
const firstName = formData?.user ? JSON.parse(formData.user).name?.firstName : null;
const lastName = formData?.user ? JSON.parse(formData.user).name?.lastName : null;
let user = await db.users.findByProvider('apple', appleUserId);
if (!user) {
user = await db.users.create({
provider: 'apple',
providerUserId: appleUserId,
email: claims.email, // may be a relay address
emailIsRelay: claims.email?.endsWith('@privaterelay.appleid.com'),
displayName: firstName && lastName ? `${firstName} ${lastName}` : null,
});
}
return user;
}
Account linking vs separate accounts
A user signs up with Google using alice@example.com. Later, they try to "Sign in with GitHub" with the same email. Do you link the accounts automatically, create a separate account, or block the login?
Automatic linking by email is dangerous. Email addresses can be reassigned (your old company email, for example). An attacker who controls a GitHub account with your email can take over your account if you auto-link. The safe approach:
- If the email exists and is verified, show a merge prompt: "An account with this email already exists. Sign in with that account to link these providers."
- Never auto-link without the user explicitly confirming the connection.
- Store provider connections in a separate table, not embedded in the users table.
CREATE TABLE user_providers (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL, -- 'google', 'github', 'apple'
provider_user_id VARCHAR(256) NOT NULL,
email TEXT,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_user_id)
);
-- Lookup: find user by provider identity
SELECT u.* FROM users u
JOIN user_providers up ON u.id = up.user_id
WHERE up.provider = $1 AND up.provider_user_id = $2;
With this model, each social provider is a separate identity document attached to a canonical user record. Users can have Google, GitHub, and Apple all linked to the same account, but each link is explicit and user-initiated.
email_verified: false in Google, a GitHub account with a private email, and an Apple account using Hide My Email before you ship. The happy path is easy. These edge cases are where real user accounts get corrupted.