Remigiusz ZalewskiRemigiusz Zalewski

Google Authentication in ASP.NET Core with React (.NET 9)

google-authentication-aspnet-core-react

Introduction

There are two common ways to add "Sign in with Google" to an app: verify a Google ID token that a client-side JS library hands you, or let ASP.NET Core's own authentication middleware run the entire OAuth redirect dance server-side. This video builds the second approach - AddGoogle alongside cookie and JWT bearer schemes, so a Google sign-in ends up producing exactly the same HttpOnly access/refresh token cookies a normal email/password login does, and the rest of the API never has to know which one the user chose.

🎬 Watch the full video here:


Three authentication schemes doing three different jobs

The API registers AddGoogle, .AddCookie(), and .AddJwtBearer() together, each responsible for a different leg of the flow:

builder.Services.AddAuthentication(opt =>
{
    opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    opt.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddCookie().AddGoogle(options =>
{
    options.ClientId = clientId;
    options.ClientSecret = clientSecret;
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters { /* ... */ };
    options.Events = new JwtBearerEvents
    {
        OnMessageReceived = context =>
        {
            context.Token = context.Request.Cookies["ACCESS_TOKEN"];
            return Task.CompletedTask;
        }
    };
});

The cookie scheme is a short-lived, temporary holding pen for the external login handshake only - AddGoogle's SignInScheme points at it, not at the app's real session. Once the Google round trip finishes, the app converts that temporary cookie identity into its own JWT and discards it. Notice also OnMessageReceived: this API doesn't expect an Authorization: Bearer header at all - it reads the access token out of an HttpOnly cookie instead, so the whole flow works without any client-side JavaScript ever touching a token.

The redirect flow, endpoint by endpoint

The React button doesn't call an SDK - it navigates the browser straight to the API:

const handleLoginWithGoogle = () => {
  window.location.href =
    'https://localhost:7141/api/account/login/google?returnUrl=http://localhost:5173'
}

The API's first endpoint issues a Results.Challenge against the Google scheme, which is what actually redirects the browser to Google's consent screen:

app.MapGet("/api/account/login/google", ([FromQuery] string returnUrl, LinkGenerator linkGenerator,
    SignInManager<User> signManager, HttpContext context) =>
{
    var properties = signManager.ConfigureExternalAuthenticationProperties("Google",
        linkGenerator.GetPathByName(context, "GoogleLoginCallback") + $"?returnUrl={returnUrl}");

    return Results.Challenge(properties, ["Google"]);
});

ConfigureExternalAuthenticationProperties bakes the callback URL (with returnUrl carried through) into the state Google will hand back, so the second endpoint knows both that the login succeeded and where the browser should end up afterward:

app.MapGet("/api/account/login/google/callback", async ([FromQuery] string returnUrl,
    HttpContext context, IAccountService accountService) =>
{
    var result = await context.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
    if (!result.Succeeded) return Results.Unauthorized();

    await accountService.LoginWithGoogleAsync(result.Principal);
    return Results.Redirect(returnUrl);
}).WithName("GoogleLoginCallback");

AuthenticateAsync(GoogleDefaults.AuthenticationScheme) is what pulls the already-validated ClaimsPrincipal back out of the temporary cookie ASP.NET Core set during the handshake - the app never talks to Google's token endpoint directly itself.

Turning a Google identity into the app's own user

LoginWithGoogleAsync does the find-or-create against Identity's UserManager<User>, using the email claim Google supplied:

var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
var user = await _userManager.FindByEmailAsync(email);

if (user == null)
{
    var newUser = new User
    {
        UserName = email,
        Email = email,
        FirstName = claimsPrincipal.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty,
        LastName = claimsPrincipal.FindFirstValue(ClaimTypes.Surname) ?? string.Empty,
        EmailConfirmed = true
    };
    await _userManager.CreateAsync(newUser);
    user = newUser;
}

await _userManager.AddLoginAsync(user, new UserLoginInfo("Google",
    claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty, "Google"));

AddLoginAsync records the external login against Identity's own AspNetUserLogins table, linking the Google account to this user row - so a returning user is matched by that link on subsequent logins, not just by re-matching the email each time.

From Google identity to the same cookies every login gets

Whether a user just signed in with Google or with an email and password, they leave AccountService the same way - a signed JWT and a refresh token, both written as HttpOnly cookies:

var (jwtToken, expirationDateInUtc) = _authTokenProcessor.GenerateJwtToken(user);
var refreshTokenValue = _authTokenProcessor.GenerateRefreshToken();

user.RefreshToken = refreshTokenValue;
user.RefreshTokenExpiresAtUtc = DateTime.UtcNow.AddDays(7);
await _userManager.UpdateAsync(user);

_authTokenProcessor.WriteAuthTokenAsHttpOnlyCookie("ACCESS_TOKEN", jwtToken, expirationDateInUtc);
_authTokenProcessor.WriteAuthTokenAsHttpOnlyCookie("REFRESH_TOKEN", user.RefreshToken, refreshTokenExpirationDateInUtc);
public void WriteAuthTokenAsHttpOnlyCookie(string cookieName, string token, DateTime expiration)
{
    _httpContextAccessor.HttpContext.Response.Cookies.Append(cookieName, token, new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Strict,
        Expires = expiration
    });
}

This is the payoff of the whole design: by the time LoginWithGoogleAsync finishes, there is no meaningful difference left between a Google user and a password user - both hold the same two HttpOnly cookies, and every other endpoint in the API (RequireAuthorization()) just reads the JWT cookie without caring how the user originally authenticated.

Common pitfalls

  • Confusing the cookie scheme with the app's real session. It's a temporary holding pen for the external login handshake, not the JWT that protects the rest of the API.
  • Forgetting the callback route name has to match GetPathByName. WithName("GoogleLoginCallback") and the lookup in the challenge endpoint have to agree exactly.
  • Not calling AddLoginAsync. Without it, a returning Google user only ever gets matched by email, and the login provider link Identity is designed to track never gets recorded.
  • AllowCredentials() missing from the CORS policy. HttpOnly cookies won't be sent cross-origin from React without it.
  • Assuming the client needs a Google JS SDK at all. This flow needs nothing on the client beyond a link/redirect - all the OAuth complexity lives server-side.

Key Takeaways

  • AddGoogle plus a cookie scheme lets ASP.NET Core run the entire OAuth redirect flow server-side - React only navigates to a URL and back.
  • The cookie scheme is a temporary vessel for the external login handshake; the app's real session is the JWT bearer scheme reading an HttpOnly cookie.
  • LoginWithGoogleAsync find-or-creates an Identity user from Google's claims and records the link via AddLoginAsync.
  • A Google login and a password login converge on the exact same HttpOnly access/refresh token cookies, so the rest of the API is identity-provider-agnostic.

Get the Full Source Code

The complete runnable solution - the ASP.NET Core Identity + Google OAuth API and the React app driving the redirect - is available to Patreon supporters. If you want to wire your own app up to Google instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources