Starting this month, every user who signs in through a social OAuth provider (Google, GitHub, or any future provider) is required to enroll a second authentication factor within 24 hours. If they do not, their next login will be held in a pending state until they complete the 2FA challenge. This post explains the threat model, the implementation, and how we handle the edge cases.
The Threat Model
Social sign-in delegates authentication to a third party. When a user clicks "Sign in with Google," UniAuth receives an OAuth grant that asserts "Google believes this person controls [email protected]." That assertion is only as strong as Google's own security posture for that user.
Consider these scenarios:
- Compromised OAuth provider account. The user's Google account is taken over via phishing or a session hijack. The attacker can now mint valid OAuth grants for UniAuth without knowing anything about the user's UniAuth-specific credentials.
- Stolen OAuth grants. Authorization codes or access tokens intercepted in transit (e.g., via a compromised redirect URI registered through a subdomain takeover) allow an attacker to complete the OAuth flow.
- Provider-side credential stuffing. Automated attacks against the upstream provider that succeed because the user reused their password. The attacker gets a valid session at the provider, then uses it to log in to every relying party the user has connected.
In all three cases, UniAuth's social login flow would have accepted the attacker as the legitimate user. A second factor that is independent of the OAuth provider breaks this chain: even if the provider is fully compromised, the attacker cannot produce a valid TOTP code, respond to an SMS challenge, or tap a passkey that lives on the user's physical device.
The Pending-Session State Machine
The core implementation lives in lib/social-pending.ts. When a user completes a social OAuth callback and does not have 2FA enabled, the system does not issue a real session. Instead, it enters the pending-session state:
// Simplified flow
OAuth callback (/api/auth/google/callback)
|
v
User has 2FA? ──yes──> Issue normal session, redirect to /account
|
no
|
v
Create social_pending_session row in DB
|
v
Set signed JWT cookie ("social_login_pending")
|
v
Redirect to /login?step=social_2fa
The pending session is a database row in social_pending_sessions with these fields: a UUID primary key (used as the JWT's jti), user_id, provider, redirect (where to send them after success), attempts (capped at 5), used_at (set once to prevent replay), and expires_at (10 minutes from creation).
The JWT cookie carries only the jti. All authoritative state lives in the database, which means a stolen cookie cannot bypass the attempt counter or be replayed after the session is consumed.
Concurrency Limits
To prevent an attacker who has a stolen provider grant from flooding the database with pending rows, we enforce a maximum of 3 concurrent pending sessions per user. When a fourth is created, the oldest unused pending session is burned (its used_at is set to NOW()). This limits resource consumption without blocking legitimate retry behavior, such as a user who clicks "Sign in with Google" multiple times because the page was slow.
The 2FA Challenge Flow
When the user lands on /login?step=social_2fa, the page reads the social_login_pending cookie, extracts the jti, and presents the 2FA enrollment or verification form. The flow branches depending on whether the user has already enrolled a second factor:
- User has 2FA methods enrolled: They are prompted to verify with their existing method (TOTP, SMS, or email code). On success,
used_atis set on the pending session, a real session cookie is issued, and they are redirected to the original destination. - User has no 2FA methods: They are guided through TOTP setup (scan a QR code, enter the confirmation code). On success, the 2FA method is saved, the pending session is consumed, and a real session is issued.
Each verification attempt increments the attempts counter. After 5 failed attempts, the pending session is burned and the user must restart the social login flow from scratch. This prevents brute-force attacks against the TOTP code space (which is only 6 digits, so 5 attempts is far below the ~1 million possible values).
The Lockout Trap
A subtle problem: what happens to a user who signed up exclusively through social login, never set a password, never enrolled 2FA, and now cannot access their OAuth provider? They are locked out of UniAuth entirely.
We considered several approaches:
- Grace period with degraded access. Allow the user to log in without 2FA for 24 hours, but restrict sensitive operations. Rejected because it defeats the purpose during the highest-risk window (first login from a potentially compromised provider).
- Force password creation. Require the user to set a password before allowing social-only login. Rejected because it undermines the UX promise of passwordless social sign-in.
- Magic-link recovery. Allow the user to request a magic link sent to their verified email address, which bypasses the social OAuth flow entirely and lands them in a session where they can enroll 2FA. This is what we implemented.
The magic-link recovery path uses lib/magic-link.ts, which issues a time-limited, single-use token sent to the user's email. The link creates a real session (with a flag indicating it was magic-link-authenticated) and redirects to the 2FA enrollment page. Once 2FA is enrolled, subsequent social logins proceed normally.
Implementation Details
The pending-session JWT is signed with HS256 using the same JWT_SECRET as the main auth tokens. It has a short TTL (10 minutes) and is httpOnly, secure, and sameSite: lax. The cookie name is social_login_pending, distinct from the main uniauth_session cookie, so middleware can distinguish between a fully authenticated user and one in the pending state.
// From lib/social-pending.ts
const COOKIE_NAME = "social_login_pending"
const TOKEN_TTL_SECONDS = 10 * 60
const MAX_VERIFY_ATTEMPTS = 5
const MAX_PENDING_PER_USER = 3
Middleware treats the pending state as unauthenticated for all routes except /login and /api/auth/2fa-verify. This means a user in the pending state cannot access /account, /admin, or any API endpoints. They can only complete the 2FA challenge or abandon the flow.
What About Existing Users?
Users who registered before this change and have been using social login without 2FA are not retroactively locked out. Instead, on their next social login, they enter the pending state and are prompted to enroll. The 24-hour grace period mentioned in the announcement refers to the informational email we send: "You have 24 hours to set up 2FA before your next login requires it." The enforcement is immediate upon next login, not after a timer.
Users who already have 2FA enrolled see no change. Their social login callback detects the existing 2FA enrollment, issues a real session, and proceeds as before.
Why This Matters
The security case rests on three well-established properties of federated auth:
- Social providers' own breaches ripple downstream. When an upstream IdP's database leaks — or a specific user's upstream account is compromised via phishing — every downstream app that treats a successful social login as sufficient authentication inherits that compromise. Mandatory 2FA on the UniAuth side breaks that chain.
- Credential stuffing is the dominant attack against federated login. Public incident data from the FIDO Alliance and the Verizon DBIR consistently shows stolen-credential replay is the top vector, and TOTP/passkey 2FA stops it cold because the second factor is not present in any credential dump.
- Enrollment timing matters. Industry retention data (e.g. Google's published reports on 2SV adoption) shows 2FA enrolled during account creation sticks far better than 2FA added after the fact — so we make it part of the initial flow rather than a later nag.
We haven't been running long enough to publish our own numbers yet. When we have, they'll appear here with the SQL we used to compute them.
Looking Ahead
We are evaluating making passkeys the default 2FA method for social sign-in, since they provide both the second factor and phishing resistance in a single gesture. For now, TOTP, SMS, and email codes are all accepted. The pending-session architecture is flexible enough to accommodate additional challenge types without changing the flow.