OAuth providers like Google and GitHub are excellent for reducing friction during onboarding. They handle the identity verification so you don't have to. The challenge arises when you want to add an extra layer of security, like Two-Factor Authentication (2FA), to these accounts.
Most standard 2FA implementations rely on a password confirmation step to enable or disable settings. OAuth users don't have a local password. This creates a gap in the security flow.
I recently implemented a solution using better-auth in a Next.js 16 application that bridges this gap. It allows OAuth users to enable TOTP (Time-based One-Time Password) and manage trusted devices without needing a password fallback.
Here is how I structured the architecture.
The Core Concept
We need to treat OAuth 2FA differently from standard email/password 2FA. Since we cannot validate a password, we validate the active OAuth session. The flow relies on three main parts:
- A custom API route to handle TOTP logic.
- A modified OAuth callback to intercept logins.
- Client components to handle the verification UX.
The Backend Logic
The heavy lifting happens in a custom route handler. I created src/app/api/auth/oauth-two-factor/route.ts. This endpoint acts as a dispatcher for 2FA actions specifically for social login users.
It handles enabling 2FA, verifying the code, and generating backup codes.
// src/app/api/auth/oauth-two-factor/route.ts
// Enabling 2FA
const result = await enableTwoFactorForOAuthUser(userId, issuer);
// Verifying a code
const isValid = await verifyTotpForOAuthUser(userId, code);
// Generating backup codes
const backupCodes = await generateBackupCodesForOAuthUser(userId);
These functions, located in src/lib/two-factor.ts, interact directly with the database. They store secrets in an encrypted format and ensure backup codes are generated securely.
Intercepting the Login
When a user logs in via Google, better-auth handles the callback. We need to hook into this process. I modified src/app/api/auth/oauth-callback/route.ts.
We check the user's profile immediately after authentication. If twoFactorEnabled is true, we pause. We check if the current device is "trusted" via a cookie.
// src/app/api/auth/oauth-callback/route.ts
if (user?.twoFactorEnabled && user?.trustedDeviceEnabled) {
// If trusted, set the verification cookie and let them in
response.cookies.set('two-factor-verified', 'true', {...});
} else {
// If not trusted or no cookie, redirect to verification page
}
This logic ensures that a user with 2FA enabled cannot bypass the second step unless they have previously verified the specific device.
The Verification UI
The user gets redirected to src/app/auth/verify-2fa/page.tsx if a check is required. This page is a server component that validates the session exists before loading the client form.
The client component, verify-2fa-client.tsx, handles the user input. It sends the TOTP code to our custom API route.
// src/components/two-factor-verify-form.tsx
const response = await fetch('/api/auth/oauth-two-factor', {
method: 'POST',
body: JSON.stringify({
action: 'verify',
code: verificationCode,
}),
});
If the API returns success, we set a two-factor-verified cookie. This cookie acts as the "proof" that the second factor was passed. The user is then redirected to their dashboard.
Enabling 2FA on the Profile
We also need a way for users to turn this feature on. Inside the user profile, I added a component that generates a QR code.
Since we don't ask for a password, we rely on the active session. When the user clicks "Enable", we hit the API.
// src/components/two-factor-enable-form.tsx
const response = await fetch('/api/auth/oauth-two-factor', {
method: 'POST',
body: JSON.stringify({
action: 'enable',
issuer: 'Next.js Better Auth',
}),
});
The server returns a secret and a QR code URL. The user scans it, enters a code to confirm, and the database updates to reflect that 2FA is now active.
Trusted Devices
Repeatedly entering codes is annoying. To solve this, we implemented a toggle for "Trusted Devices".
When verification is successful, the user can choose to trust the device. This sets a long-lived cookie (usually 30 days). The OAuth callback looks for this cookie on subsequent logins. If present and valid, it bypasses the manual code entry.
// src/components/trusted-device-toggle.tsx
const response = await fetch('/api/auth/oauth-two-factor', {
method: 'POST',
body: JSON.stringify({
action: 'toggleTrustedDevice',
enable: newValue,
}),
});
Security Considerations
This flow maintains high security standards. All API routes require an active session. You cannot hit the 2FA endpoints without being logged in first.
Secrets are stored encrypted. Backup codes are single-use; they are regenerated whenever a user requests new ones. The cookies used for trusted devices are HTTP-only and Secure, preventing client-side script access.
Summary
By decoupling the 2FA logic from password validation, we allow OAuth users to enjoy the same security benefits as email/password users. The combination of custom route handlers and session checks in Next.js 16 provides a robust solution.
Top comments (0)