Home / Security / OAuth 2.0 in Minutes: Social Login with Noundry Authnz
Security

OAuth 2.0 in Minutes: Social Login with Noundry Authnz

Add Google and GitHub social login to your ASP.NET Core Razor Pages app in minutes using Noundry Authnz. Zero session state, PKCE by default, and cookie-based claims — the way stateless auth should be.

G
Chef George
February 5, 2026 20 mins Intermediate 864 views

The Problem

You need to let users sign in with their Google or GitHub accounts, but the official ASP.NET Identity system drags in Entity Framework, session state, and a mountain of scaffolded UI you will never use. You want OAuth 2.0 with PKCE, stateless cookie-based authentication, and zero middleware bloat — just plug in your client credentials and go.

Ingredients

Noundry.Authnz

v1.3.0

OAuth 2.0 authentication with built-in providers for Google, GitHub, Microsoft, Apple, and Facebook

Noundry.Tuxedo

vlatest

Lightweight ORM with raw SQL support for user persistence

Noundry.Tuxedo.Bowtie

vlatest

Automatic schema migrations from model attributes

Npgsql

vlatest

PostgreSQL ADO.NET data provider

.NET 10 SDK

v10.0

Latest .NET runtime and SDK

PostgreSQL

v17

Database server for storing user accounts

Gather these packages before we start cooking. Noundry.Authnz handles the entire OAuth flow; Tuxedo handles user persistence with raw SQL.

Preparation

dotnet new razor -n SocialLoginApp
cd SocialLoginApp
dotnet add package Noundry.Authnz
dotnet add package Noundry.Tuxedo
dotnet add package Noundry.Tuxedo.Bowtie
dotnet add package Npgsql

2. Register OAuth applications with providers

Google:

  1. Go to Google Cloud Console > APIs & Services > Credentials
  2. Create an OAuth 2.0 Client ID (Web application)
  3. Set Authorized redirect URI to https://localhost:5001/oauth/callback/google

GitHub:

  1. Go to GitHub Developer Settings > OAuth Apps > New OAuth App
  2. Set Authorization callback URL to https://localhost:5001/oauth/callback/github
  3. Important: Under permissions, ensure the user:email scope is enabled — GitHub does not send email by default

3. Key concepts before we code

  • Stateless auth — Noundry Authnz stores everything in an encrypted NoundryAuth cookie. No session state, no distributed cache, no sticky sessions.
  • PKCE enabled by default — Every OAuth flow uses SHA-256 PKCE, even for confidential clients. This is a security best practice that prevents authorization code interception.
  • Cookie settings — HttpOnly, SameSite=Lax, 30-day sliding expiration. The cookie is encrypted with ASP.NET Core Data Protection.
  • Built-in endpoints — Authnz registers three routes automatically: /oauth/login/{provider}, /oauth/callback/{provider}, and /oauth/logout.

The Code

Let us wire everything up. We will start with configuration, then the user model and service, the OAuth success handler, and finally the Razor Pages.

Provider Configuration

Add your OAuth client credentials to appsettings.json. Never commit real secrets — use environment variables or user-secrets in production.

appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=socialloginapp;Username=postgres;Password=secret"
  },
  "OAuth": {
    "DefaultRedirectUri": "/dashboard",
    "LoginPath": "/login",
    "Providers": {
      "google": {
        "ClientId": "YOUR_GOOGLE_CLIENT_ID",
        "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET"
      },
      "github": {
        "ClientId": "YOUR_GITHUB_CLIENT_ID",
        "ClientSecret": "YOUR_GITHUB_CLIENT_SECRET"
      }
    }
  }
}

User Model

Define a user model with Tuxedo/Bowtie attributes. We track the OAuth provider and provider-specific user ID so the same person can log in repeatedly and map to the same row.

Models/AppUser.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Noundry.Tuxedo.Contrib;

namespace SocialLoginApp.Models;

[Table("app_users")]
public class AppUser
{
    [ExplicitKey]
    [Column("id")]
    public Guid Id { get; set; } = Guid.NewGuid();

    [Column("email", TypeName = "varchar(320)")]
    public string Email { get; set; } = string.Empty;

    [Column("display_name", TypeName = "varchar(200)")]
    public string DisplayName { get; set; } = string.Empty;

    [Column("avatar_url", TypeName = "text")]
    public string? AvatarUrl { get; set; }

    [Column("oauth_provider", TypeName = "varchar(50)")]
    public string OAuthProvider { get; set; } = string.Empty;

    [Column("oauth_provider_id", TypeName = "varchar(255)")]
    public string OAuthProviderId { get; set; } = string.Empty;

    [Column("created_at")]
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    [Column("last_login_at")]
    public DateTime LastLoginAt { get; set; } = DateTime.UtcNow;
}

User Service

The service uses raw SQL via Tuxedo extension methods to find or create users. No Entity Framework, no LINQ-to-SQL — just clean, explicit queries.

Services/UserService.cs
using System.Data;
using Noundry.Tuxedo;
using Noundry.Tuxedo.Contrib;
using SocialLoginApp.Models;

namespace SocialLoginApp.Services;

public interface IUserService
{
    Task<AppUser> FindOrCreateFromOAuthAsync(
        string provider, string providerId, string email,
        string displayName, string? avatarUrl);
    Task<AppUser?> GetByIdAsync(Guid id);
}

public class UserService : IUserService
{
    private readonly IDbConnection _db;

    public UserService(IDbConnection db) => _db = db;

    public async Task<AppUser> FindOrCreateFromOAuthAsync(
        string provider, string providerId, string email,
        string displayName, string? avatarUrl)
    {
        var existing = await _db.QueryFirstOrDefaultAsync<AppUser>(
            "SELECT * FROM app_users WHERE oauth_provider = @Provider AND oauth_provider_id = @ProviderId",
            new { Provider = provider, ProviderId = providerId });

        if (existing != null)
        {
            existing.LastLoginAt = DateTime.UtcNow;
            existing.AvatarUrl = avatarUrl ?? existing.AvatarUrl;
            existing.DisplayName = displayName;
            await _db.UpdateAsync(existing);
            return existing;
        }

        var user = new AppUser
        {
            Email = email,
            DisplayName = displayName,
            AvatarUrl = avatarUrl,
            OAuthProvider = provider,
            OAuthProviderId = providerId,
            CreatedAt = DateTime.UtcNow,
            LastLoginAt = DateTime.UtcNow
        };

        await _db.InsertAsync(user);
        return user;
    }

    public async Task<AppUser?> GetByIdAsync(Guid id)
    {
        return await _db.GetAsync<AppUser>(id);
    }
}

OAuth Success Handler

This is the heart of the integration. Authnz calls your handler after a successful OAuth callback. You receive the provider claims, create or find the user in your database, and return additional claims that get baked into the authentication cookie.

Notice how we handle the GitHub email edge case — if the user has a private email, GitHub returns a noreply fallback address.

Auth/OAuthSuccessHandler.cs
using System.Security.Claims;
using Noundry.Authnz;
using SocialLoginApp.Services;

namespace SocialLoginApp.Auth;

public class AppOAuthSuccessHandler : IOAuthSuccessHandler
{
    public async Task<OAuthSuccessResult?> HandleAsync(OAuthSuccessContext context)
    {
        var userService = context.GetRequiredService<IUserService>();

        var providerId = context.Claims
            .FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
        var email = context.Claims
            .FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? string.Empty;
        var displayName = context.Claims
            .FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? "Unknown";
        var avatarUrl = context.Claims
            .FirstOrDefault(c => c.Type == "avatar_url")?.Value;

        // GitHub may return a noreply email — handle gracefully
        if (string.IsNullOrWhiteSpace(email) || email.Contains("noreply"))
        {
            email = $"{providerId}+{context.Provider}@users.noreply.oauth";
        }

        var user = await userService.FindOrCreateFromOAuthAsync(
            context.Provider, providerId, email, displayName, avatarUrl);

        return new OAuthSuccessResult
        {
            AdditionalClaims = new List<Claim>
            {
                new("app_user_id", user.Id.ToString()),
                new("app_display_name", user.DisplayName),
                new("app_avatar_url", user.AvatarUrl ?? string.Empty)
            },
            OverrideRedirectUri = "/dashboard"
        };
    }
}

Program.cs

Two lines do the heavy lifting: AddNoundryOAuth registers the OAuth services and reads provider config from appsettings.json, while UseNoundryOAuth adds the middleware that handles /oauth/login, /oauth/callback, and /oauth/logout routes.

Critical: Call UseNoundryOAuth() after UseRouting() but before MapRazorPages().

Program.cs
using System.Data;
using Npgsql;
using Noundry.Authnz;
using Noundry.Authnz.Extensions;
using Noundry.Tuxedo.Bowtie;
using SocialLoginApp.Auth;
using SocialLoginApp.Services;
using SocialLoginApp.Models;

var builder = WebApplication.CreateBuilder(args);

// Database connection
builder.Services.AddScoped<IDbConnection>(_ =>
    new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));

// Tuxedo Bowtie migrations
builder.Services.AddBowtie(options =>
{
    options.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection")!;
    options.DatabaseType = DatabaseType.PostgreSql;
    options.RegisterModel<AppUser>();
});

// Application services
builder.Services.AddScoped<IUserService, UserService>();

// Noundry Authnz — reads OAuth config from appsettings.json
builder.Services.AddNoundryOAuth(builder.Configuration);
builder.Services.AddScoped<IOAuthSuccessHandler, AppOAuthSuccessHandler>();

builder.Services.AddRazorPages();
builder.Services.AddControllers();

var app = builder.Build();

// Run Bowtie migrations on startup
await app.Services.GetRequiredService<IBowtieRunner>().MigrateAsync();

app.UseStaticFiles();
app.UseRouting();

// Noundry OAuth middleware — MUST be after UseRouting()
app.UseNoundryOAuth();

app.MapRazorPages();
app.MapControllers();

app.Run();

Tag Helper Registration

Add the Noundry.Authnz tag helper to your _ViewImports.cshtml so the OAuth login, logout, and status components are available in all Razor Pages.

Pages/_ViewImports.cshtml
@using SocialLoginApp
@namespace SocialLoginApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Noundry.Authnz

Login Page

The <noundry-oauth-login> tag helper renders styled OAuth buttons that link to /oauth/login/{provider}. Use show-all="true" to render buttons for every configured provider, or specify individual providers for fine-grained control.

Pages/Login.cshtml
@page
@model SocialLoginApp.Pages.LoginModel
@{
    ViewData["Title"] = "Sign In";
}

<div class="min-h-screen flex items-center justify-center bg-zinc-950">
    <div class="w-full max-w-md p-8 rounded-2xl bg-zinc-900/60 border border-zinc-800 backdrop-blur-xl">
        <h1 class="text-3xl font-bold text-center text-white mb-2">Welcome Back</h1>
        <p class="text-zinc-400 text-center mb-8">Sign in with your favorite provider</p>

        <div class="space-y-4">
            <noundry-oauth-login provider="google"
                button-text="Continue with Google"
                css-class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg bg-white text-zinc-900 font-medium hover:bg-zinc-100 transition-colors" />

            <noundry-oauth-login provider="github"
                button-text="Continue with GitHub"
                css-class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg bg-zinc-800 text-white font-medium hover:bg-zinc-700 transition-colors border border-zinc-700" />
        </div>

        <p class="text-xs text-zinc-500 text-center mt-6">
            By signing in, you agree to our Terms of Service and Privacy Policy.
            Your authentication is handled via OAuth 2.0 with PKCE — we never see your password.
        </p>
    </div>
</div>
Pages/Login.cshtml.cs
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace SocialLoginApp.Pages;

public class LoginModel : PageModel
{
    public void OnGet()
    {
        // If already authenticated, redirect to dashboard
        if (User.Identity?.IsAuthenticated == true)
        {
            Response.Redirect("/dashboard");
        }
    }
}

Dashboard Page

After a successful OAuth flow, all user claims live in the encrypted NoundryAuth cookie. Access them with the standard User.Claims API. No session state, no database round-trip — everything you need is in the cookie.

Pages/Dashboard.cshtml
@page
@model SocialLoginApp.Pages.DashboardModel
@{
    ViewData["Title"] = "Dashboard";
}

<div class="min-h-screen bg-zinc-950 p-8">
    <div class="max-w-4xl mx-auto">
        <div class="flex items-center gap-4 mb-8">
            @if (!string.IsNullOrEmpty(Model.AvatarUrl))
            {
                <img src="@Model.AvatarUrl" alt="Avatar"
                     class="w-16 h-16 rounded-full border-2 border-amber-500" />
            }
            <div>
                <h1 class="text-2xl font-bold text-white">Welcome, @Model.DisplayName</h1>
                <p class="text-zinc-400">Signed in via @Model.Provider</p>
            </div>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
            <div class="p-6 rounded-xl bg-zinc-900/60 border border-zinc-800">
                <h2 class="text-lg font-semibold text-white mb-4">Your Profile</h2>
                <dl class="space-y-3">
                    <div>
                        <dt class="text-xs text-zinc-500 uppercase">Email</dt>
                        <dd class="text-zinc-200">@Model.Email</dd>
                    </div>
                    <div>
                        <dt class="text-xs text-zinc-500 uppercase">Provider</dt>
                        <dd class="text-zinc-200">@Model.Provider</dd>
                    </div>
                    <div>
                        <dt class="text-xs text-zinc-500 uppercase">App User ID</dt>
                        <dd class="text-zinc-200 font-mono text-sm">@Model.AppUserId</dd>
                    </div>
                </dl>
            </div>

            <div class="p-6 rounded-xl bg-zinc-900/60 border border-zinc-800">
                <h2 class="text-lg font-semibold text-white mb-4">All Cookie Claims</h2>
                <div class="space-y-2 max-h-64 overflow-y-auto">
                    @foreach (var claim in Model.AllClaims)
                    {
                        <div class="flex justify-between text-sm border-b border-zinc-800 pb-1">
                            <span class="text-amber-400 font-mono truncate mr-2">@claim.Type.Split("/").Last()</span>
                            <span class="text-zinc-300 truncate">@claim.Value</span>
                        </div>
                    }
                </div>
            </div>
        </div>
    </div>
</div>
Pages/Dashboard.cshtml.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace SocialLoginApp.Pages;

public class DashboardModel : PageModel
{
    public string DisplayName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Provider { get; set; } = string.Empty;
    public string AppUserId { get; set; } = string.Empty;
    public string? AvatarUrl { get; set; }
    public List<Claim> AllClaims { get; set; } = new();

    public IActionResult OnGet()
    {
        if (User.Identity?.IsAuthenticated != true)
        {
            return Redirect("/login");
        }

        DisplayName = User.FindFirstValue("app_display_name")
            ?? User.FindFirstValue(ClaimTypes.Name)
            ?? "Unknown";
        Email = User.FindFirstValue(ClaimTypes.Email) ?? "N/A";
        Provider = User.FindFirstValue("provider") ?? "Unknown";
        AppUserId = User.FindFirstValue("app_user_id") ?? "N/A";
        AvatarUrl = User.FindFirstValue("app_avatar_url");
        AllClaims = User.Claims.ToList();

        return Page();
    }
}

Navbar Partial

The <noundry-oauth-status> tag helper renders the user's avatar and name when authenticated, and the <noundry-oauth-logout> tag helper renders a sign-out button that clears the NoundryAuth cookie and redirects to the home page.

Pages/Shared/_Navbar.cshtml
<nav class="sticky top-0 z-50 bg-zinc-950/80 backdrop-blur-xl border-b border-zinc-800">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between items-center h-16">
            <a href="/" class="text-xl font-bold text-white">
                Social<span class="text-amber-400">Login</span>App
            </a>

            <div class="flex items-center gap-4">
                @if (User.Identity?.IsAuthenticated == true)
                {
                    <a href="/dashboard" class="text-zinc-300 hover:text-white transition-colors">
                        Dashboard
                    </a>
                    <noundry-oauth-status show-avatar="true"
                        css-class="flex items-center gap-2 text-sm text-zinc-300" />
                    <noundry-oauth-logout button-text="Sign Out"
                        css-class="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors text-sm" />
                }
                else
                {
                    <a href="/login"
                       class="px-4 py-2 rounded-lg bg-amber-500 text-zinc-900 font-medium hover:bg-amber-400 transition-colors text-sm">
                        Sign In
                    </a>
                }
            </div>
        </div>
    </div>
</nav>

Plating

You now have a fully functional OAuth 2.0 social login system with zero session state. Here is what we accomplished:

  • Two-line setup — AddNoundryOAuth and UseNoundryOAuth handle the entire OAuth flow, PKCE challenges, and cookie management
  • Stateless architecture — All user data lives in an encrypted, HttpOnly cookie with a 30-day sliding expiration. No session store, no Redis, no sticky sessions
  • PKCE by default — Every authorization request includes a SHA-256 PKCE challenge, protecting against code interception attacks even for server-side apps
  • Database user sync — The IOAuthSuccessHandler pattern gives you a clean hook to create or update users on every login, with custom claims baked into the cookie
  • Provider-agnostic — Adding Microsoft, Apple, or Facebook login is one more block in appsettings.json and zero code changes

Next steps:

  • Add role-based authorization by including role claims in your OAuthSuccessResult
  • Implement account linking so users who sign in with different providers can merge into one account
  • Add a /profile page where users can update their display name stored in Tuxedo
  • Explore the Custom provider type in Authnz to integrate with your own identity server

Was this recipe helpful?

G

Chef George

.NET Architect & Head Chef

25+ years crafting enterprise C# solutions. Creator of the Noundry platform. Passionate about clean architecture, cloud-agnostic deployments, and code that cooks.