External Login and Two-Factor Authentication

External login (OAuth/OpenID Connect) lets users sign in with their existing Google, Microsoft, or GitHub account โ€” no new password to create or remember. Two-factor authentication (2FA) adds a second verification step beyond the password, dramatically reducing account takeover risk from stolen credentials. Both are production-grade features that significantly improve your application’s security posture and user convenience. ASP.NET Core Identity has built-in support for both with minimal configuration.

Google OAuth Login

// dotnet add package Microsoft.AspNetCore.Authentication.Google

// โ”€โ”€ Program.cs โ€” configure OAuth providers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
builder.Services
    .AddAuthentication()
    .AddGoogle(options =>
    {
        options.ClientId     = builder.Configuration["Authentication:Google:ClientId"]!;
        options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]!;
        options.Scope.Add("profile");
        // Map Google profile picture to a claim
        options.ClaimActions.MapJsonKey("picture", "picture");
    })
    .AddMicrosoftAccount(options =>
    {
        options.ClientId     = builder.Configuration["Authentication:Microsoft:ClientId"]!;
        options.ClientSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"]!;
    });

// โ”€โ”€ AccountController โ€” external login flow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Step 1: Redirect to external provider
[HttpPost, ValidateAntiForgeryToken]
public IActionResult ExternalLogin(string provider, string? returnUrl = null)
{
    var redirectUrl = Url.Action(nameof(ExternalLoginCallback),
        new { returnUrl });
    var properties = signInManager.ConfigureExternalAuthenticationProperties(
        provider, redirectUrl);
    return Challenge(properties, provider);
}

// Step 2: Handle callback after external provider redirects back
[HttpGet]
public async Task<IActionResult> ExternalLoginCallback(string? returnUrl = null)
{
    var info = await signInManager.GetExternalLoginInfoAsync();
    if (info is null) return RedirectToAction(nameof(Login));

    // Sign in if this external login is already linked
    var result = await signInManager.ExternalLoginSignInAsync(
        info.LoginProvider, info.ProviderKey, isPersistent: false);

    if (result.Succeeded)
    {
        if (Url.IsLocalUrl(returnUrl)) return Redirect(returnUrl!);
        return RedirectToAction("Index", "Home");
    }

    // First time โ€” create a new local account linked to the external login
    var email = info.Principal.FindFirstValue(ClaimTypes.Email)!;
    var user  = new ApplicationUser { UserName = email, Email = email };

    var createResult = await userManager.CreateAsync(user);
    if (createResult.Succeeded)
    {
        await userManager.AddLoginAsync(user, info);
        await signInManager.SignInAsync(user, isPersistent: false);
        return RedirectToAction("Index", "Home");
    }

    foreach (var err in createResult.Errors)
        ModelState.AddModelError("", err.Description);
    return View("ExternalLoginFailure");
}
Note: External login credentials (the provider name and provider key) are stored in the AspNetUserLogins table. When a user returns and authenticates with Google, signInManager.ExternalLoginSignInAsync() looks up the provider key in this table to find the linked local account. One local user account can have multiple external logins (Google, Microsoft, GitHub) linked to it. This allows a user to sign in with any of their linked providers and always land on the same account.
Tip: Use dotnet user-secrets set "Authentication:Google:ClientId" "your-client-id" to store OAuth credentials during development. Never commit OAuth credentials to source control โ€” they grant access to your OAuth application and can be used to impersonate your application or access API quotas. For production, use environment variables or Azure Key Vault. Register your OAuth application at console.cloud.google.com (Google) and portal.azure.com (Microsoft) and add https://localhost:PORT/signin-google as an authorised redirect URI during development.
Warning: When linking an external login to an existing account, verify that the email from the external provider matches the email of the local account if you use email as the identifier. Without this check, an attacker can register a local account with victim@example.com, then link a Google account where they control victim@example.com to it โ€” bypassing email confirmation. Always verify the external provider’s email claim matches the existing account’s email before linking.

Two-Factor Authentication (2FA)

// โ”€โ”€ Enable 2FA โ€” generate authenticator key for the user โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpGet, Authorize]
public async Task<IActionResult> EnableAuthenticator()
{
    var user = await userManager.GetUserAsync(User);

    // Get or generate the shared key for TOTP (Google Authenticator, Authy)
    var unformattedKey = await userManager.GetAuthenticatorKeyAsync(user!);
    if (string.IsNullOrEmpty(unformattedKey))
    {
        await userManager.ResetAuthenticatorKeyAsync(user!);
        unformattedKey = await userManager.GetAuthenticatorKeyAsync(user!);
    }

    // Format key as groups of 4 for readability: AAAA-BBBB-CCCC-DDDD
    var sharedKey = FormatKey(unformattedKey!);

    // Generate the QR code URI for authenticator apps
    var email  = await userManager.GetEmailAsync(user!);
    var qrUri  = GenerateQrCodeUri(email!, unformattedKey!);

    return View(new EnableAuthenticatorViewModel { SharedKey = sharedKey, QrCodeUri = qrUri });
}

// โ”€โ”€ Verify the TOTP code and enable 2FA โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpPost, Authorize, ValidateAntiForgeryToken]
public async Task<IActionResult> EnableAuthenticator(EnableAuthenticatorViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    var user = await userManager.GetUserAsync(User);
    var code = model.Code.Replace(" ", "").Replace("-", "");   // normalise

    var isValid = await userManager.VerifyTwoFactorTokenAsync(
        user!, userManager.Options.Tokens.AuthenticatorTokenProvider, code);

    if (!isValid)
    {
        ModelState.AddModelError("Code", "Verification code is invalid.");
        return View(model);
    }

    await userManager.SetTwoFactorEnabledAsync(user!, true);
    return RedirectToAction(nameof(TwoFactorAuthenticationConfirmation));
}

// โ”€โ”€ Login with 2FA โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> LoginWith2fa(LoginWith2faViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    var code = model.TwoFactorCode.Replace(" ", "").Replace("-", "");
    var result = await signInManager.TwoFactorAuthenticatorSignInAsync(
        code, model.RememberMe, model.RememberMachine);

    if (result.Succeeded)
        return RedirectToAction("Index", "Home");

    ModelState.AddModelError("", "Invalid authenticator code.");
    return View(model);
}

Common Mistakes

Mistake 1 โ€” Not verifying external provider email matches existing account (account takeover)

โŒ Wrong โ€” attacker links their Google account to any local account by knowing the email.

โœ… Correct โ€” verify the external provider email claim matches the existing account’s email before linking.

Mistake 2 โ€” Committing OAuth ClientId/ClientSecret to source control

โŒ Wrong โ€” credentials in appsettings.json or code; attackers can impersonate the application.

โœ… Correct โ€” use User Secrets for development, environment variables or Key Vault for production.

🧠 Test Yourself

A user enables TOTP-based 2FA with Google Authenticator. They then lose their phone. What happens, and what should your application provide?