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:
- Go to Google Cloud Console > APIs & Services > Credentials
- Create an OAuth 2.0 Client ID (Web application)
- Set Authorized redirect URI to
https://localhost:5001/oauth/callback/google
GitHub:
- Go to GitHub Developer Settings > OAuth Apps > New OAuth App
- Set Authorization callback URL to
https://localhost:5001/oauth/callback/github - Important: Under permissions, ensure the
user:emailscope is enabled — GitHub does not send email by default
3. Key concepts before we code
- Stateless auth — Noundry Authnz stores everything in an encrypted
NoundryAuthcookie. 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.
{
"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.
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.
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.
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().
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.
@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.
@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>
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.
@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>
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.
<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 —
AddNoundryOAuthandUseNoundryOAuthhandle 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
IOAuthSuccessHandlerpattern 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.jsonand 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
/profilepage where users can update their display name stored in Tuxedo - Explore the
Customprovider type in Authnz to integrate with your own identity server
Was this recipe helpful?
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.