Most auth tutorials either stop at "here's how to generate a JWT" or dump a token straight into localStorage and call it done. Neither is how real apps should work.
This guide builds a complete, working authentication system end to end:
- .NET 8 Web API backend with EF Core + PostgreSQL
- Register / Login with hashed passwords
- Google OAuth sign-in
- Short-lived JWT access tokens + httpOnly refresh token cookies (the actually-secure pattern)
- Next.js 15 (App Router) + TypeScript frontend
- A real protected Dashboard page, guarded by middleware, not just a
useEffectcheck
By the end you'll have a repo structured like this:
auth-demo/
├── backend/ → ASP.NET Core Web API
│ ├── Controllers/
│ ├── Models/
│ ├── Services/
│ ├── Data/
│ └── Program.cs
└── frontend/ → Next.js + TypeScript
├── app/
│ ├── login/
│ ├── signup/
│ └── dashboard/
├── lib/
└── middleware.ts
Let's get into it.
Why this token setup (and not localStorage)
Quick reasoning before code, because this decision shapes everything downstream:
-
Access token (JWT, ~15 min expiry) → sent in the response body, kept in memory (React state/context). Never touches
localStorage, so it's not readable by injected/malicious JS (XSS). - Refresh token (long-lived, ~7 days) → stored in an httpOnly, Secure cookie. JavaScript can't read it at all; the browser sends it automatically to your refresh endpoint.
- Trade-off: access token disappears on hard refresh, so the frontend silently calls
/refreshon load to get a new one. We'll build that.
This is the pattern most production apps actually use once someone's done a security review.
Part 1 — .NET 8 Web API Backend
1. Project setup
dotnet new webapi -n AuthDemo.Api
cd AuthDemo.Api
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package BCrypt.Net-Next
dotnet add package Google.Apis.Auth
2. Models
Models/User.cs
namespace AuthDemo.Api.Models;
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Email { get; set; } = default!;
public string? PasswordHash { get; set; } // null for Google-only accounts
public string DisplayName { get; set; } = default!;
public string? GoogleId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<RefreshToken> RefreshTokens { get; set; } = new();
}
Models/RefreshToken.cs
namespace AuthDemo.Api.Models;
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public string TokenHash { get; set; } = default!;
public DateTime ExpiresAt { get; set; }
public bool Revoked { get; set; } = false;
public Guid UserId { get; set; }
public User User { get; set; } = default!;
}
We store a hash of the refresh token in the DB (same idea as password hashing) — if the database ever leaks, raw tokens aren't sitting in it.
3. DbContext
Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using AuthDemo.Api.Models;
namespace AuthDemo.Api.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.IsUnique();
modelBuilder.Entity<RefreshToken>()
.HasOne(rt => rt.User)
.WithMany(u => u.RefreshTokens)
.HasForeignKey(rt => rt.UserId);
}
}
4. appsettings.json — JWT config
{
"ConnectionStrings": {
"Default": "Host=localhost;Database=authdemo;Username=postgres;Password=yourpassword"
},
"Jwt": {
"Issuer": "AuthDemoApi",
"Audience": "AuthDemoClient",
"AccessTokenKey": "REPLACE_WITH_A_LONG_RANDOM_SECRET_AT_LEAST_32_CHARS",
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
},
"GoogleAuth": {
"ClientId": "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com"
}
}
Never commit real secrets — use dotnet user-secrets locally and environment variables in production.
5. Token service
Services/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using AuthDemo.Api.Models;
namespace AuthDemo.Api.Services;
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config) => _config = config;
public string GenerateAccessToken(User user)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim("name", user.DisplayName)
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:AccessTokenKey"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(
double.Parse(_config["Jwt:AccessTokenExpiryMinutes"]!)),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRawRefreshToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
public string HashToken(string token) =>
BCrypt.Net.BCrypt.HashPassword(token);
public bool VerifyTokenHash(string token, string hash) =>
BCrypt.Net.BCrypt.Verify(token, hash);
}
6. Auth service (business logic)
Services/AuthService.cs
using AuthDemo.Api.Data;
using AuthDemo.Api.Models;
using Google.Apis.Auth;
using Microsoft.EntityFrameworkCore;
namespace AuthDemo.Api.Services;
public class AuthService
{
private readonly AppDbContext _db;
private readonly TokenService _tokens;
private readonly IConfiguration _config;
public AuthService(AppDbContext db, TokenService tokens, IConfiguration config)
{
_db = db;
_tokens = tokens;
_config = config;
}
public async Task<User> RegisterAsync(string email, string password, string displayName)
{
if (await _db.Users.AnyAsync(u => u.Email == email))
throw new InvalidOperationException("Email already registered.");
var user = new User
{
Email = email,
DisplayName = displayName,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password)
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user;
}
public async Task<User?> ValidateCredentialsAsync(string email, string password)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user is null || user.PasswordHash is null) return null;
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? user : null;
}
public async Task<User> LoginOrCreateWithGoogleAsync(string idToken)
{
var payload = await GoogleJsonWebSignature.ValidateAsync(idToken, new GoogleJsonWebSignature.ValidationSettings
{
Audience = new[] { _config["GoogleAuth:ClientId"] }
});
var user = await _db.Users.FirstOrDefaultAsync(u => u.GoogleId == payload.Subject || u.Email == payload.Email);
if (user is null)
{
user = new User
{
Email = payload.Email,
DisplayName = payload.Name,
GoogleId = payload.Subject
};
_db.Users.Add(user);
}
else if (user.GoogleId is null)
{
user.GoogleId = payload.Subject; // link existing email/password account
}
await _db.SaveChangesAsync();
return user;
}
public async Task<(string raw, RefreshToken entity)> IssueRefreshTokenAsync(Guid userId)
{
var raw = _tokens.GenerateRawRefreshToken();
var entity = new RefreshToken
{
UserId = userId,
TokenHash = _tokens.HashToken(raw),
ExpiresAt = DateTime.UtcNow.AddDays(
double.Parse(_config["Jwt:RefreshTokenExpiryDays"]!))
};
_db.RefreshTokens.Add(entity);
await _db.SaveChangesAsync();
return (raw, entity);
}
public async Task<User?> ValidateRefreshTokenAsync(string rawToken)
{
var candidates = await _db.RefreshTokens
.Include(rt => rt.User)
.Where(rt => !rt.Revoked && rt.ExpiresAt > DateTime.UtcNow)
.ToListAsync();
var match = candidates.FirstOrDefault(rt => _tokens.VerifyTokenHash(rawToken, rt.TokenHash));
if (match is null) return null;
match.Revoked = true; // rotate: one-time use
await _db.SaveChangesAsync();
return match.User;
}
}
Refresh token rotation (revoking on use and issuing a new one) means a stolen refresh token only works once before it's dead — a real protection, not just theater.
7. Auth controller
Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using AuthDemo.Api.Services;
namespace AuthDemo.Api.Controllers;
public record RegisterRequest(string Email, string Password, string DisplayName);
public record LoginRequest(string Email, string Password);
public record GoogleLoginRequest(string IdToken);
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly AuthService _auth;
private readonly TokenService _tokens;
public AuthController(AuthService auth, TokenService tokens)
{
_auth = auth;
_tokens = tokens;
}
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest req)
{
var user = await _auth.RegisterAsync(req.Email, req.Password, req.DisplayName);
return await IssueSession(user.Id, user.Email, user.DisplayName);
}
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest req)
{
var user = await _auth.ValidateCredentialsAsync(req.Email, req.Password);
if (user is null) return Unauthorized(new { message = "Invalid email or password." });
return await IssueSession(user.Id, user.Email, user.DisplayName);
}
[HttpPost("google")]
public async Task<IActionResult> GoogleLogin(GoogleLoginRequest req)
{
var user = await _auth.LoginOrCreateWithGoogleAsync(req.IdToken);
return await IssueSession(user.Id, user.Email, user.DisplayName);
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh()
{
if (!Request.Cookies.TryGetValue("refreshToken", out var raw))
return Unauthorized();
var user = await _auth.ValidateRefreshTokenAsync(raw);
if (user is null) return Unauthorized();
return await IssueSession(user.Id, user.Email, user.DisplayName);
}
[HttpPost("logout")]
public IActionResult Logout()
{
Response.Cookies.Delete("refreshToken");
return Ok();
}
[Authorize]
[HttpGet("me")]
public IActionResult Me()
{
var email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value
?? User.FindFirst("email")?.Value;
var name = User.FindFirst("name")?.Value;
return Ok(new { email, name });
}
private async Task<IActionResult> IssueSession(Guid userId, string email, string name)
{
var accessToken = _tokens.GenerateAccessToken(new AuthDemo.Api.Models.User
{
Id = userId, Email = email, DisplayName = name
});
var (rawRefresh, _) = await _auth.IssueRefreshTokenAsync(userId);
Response.Cookies.Append("refreshToken", rawRefresh, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.None, // "Lax" is fine if frontend + API share a domain
Expires = DateTimeOffset.UtcNow.AddDays(7)
});
return Ok(new { accessToken, user = new { email, name } });
}
}
8. Program.cs — wiring it all together
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using AuthDemo.Api.Data;
using AuthDemo.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<TokenService>();
builder.Services.AddCors(opt =>
{
opt.AddPolicy("Frontend", policy =>
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()); // required so the refresh cookie is sent/received
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:AccessTokenKey"]!))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseCors("Frontend");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Backend done. POST /api/auth/register, /login, /google, /refresh, /logout, and a protected GET /api/auth/me — all working, real cookie + JWT behavior.
Part 2 — Next.js + TypeScript Frontend
1. Project setup
npx create-next-app@latest frontend --typescript --app
cd frontend
npm install axios @react-oauth/google
2. API client with auto-refresh
lib/api.ts
import axios from "axios";
export const api = axios.create({
baseURL: "http://localhost:5000/api",
withCredentials: true, // sends the httpOnly refresh cookie
});
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
const { data } = await api.post("/auth/refresh");
setAccessToken(data.accessToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return api(original);
} catch {
setAccessToken(null);
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
3. Auth context
lib/auth-context.tsx
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { api, setAccessToken } from "./api";
type AuthUser = { email: string; name: string } | null;
type AuthContextType = {
user: AuthUser;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (idToken: string) => Promise<void>;
signup: (email: string, password: string, displayName: string) => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState(true);
async function bootstrap() {
try {
const { data } = await api.post("/auth/refresh");
setAccessToken(data.accessToken);
setUser(data.user);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
bootstrap();
}, []);
async function login(email: string, password: string) {
const { data } = await api.post("/auth/login", { email, password });
setAccessToken(data.accessToken);
setUser(data.user);
}
async function loginWithGoogle(idToken: string) {
const { data } = await api.post("/auth/google", { idToken });
setAccessToken(data.accessToken);
setUser(data.user);
}
async function signup(email: string, password: string, displayName: string) {
const { data } = await api.post("/auth/register", { email, password, displayName });
setAccessToken(data.accessToken);
setUser(data.user);
}
async function logout() {
await api.post("/auth/logout");
setAccessToken(null);
setUser(null);
}
return (
<AuthContext.Provider value={{ user, loading, login, loginWithGoogle, signup, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
bootstrap() runs on every full page load and silently trades the refresh cookie for a new access token — this is what makes "stay logged in after refresh" work without ever touching localStorage.
Wrap the app in app/layout.tsx:
import { AuthProvider } from "@/lib/auth-context";
import { GoogleOAuthProvider } from "@react-oauth/google";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
<AuthProvider>{children}</AuthProvider>
</GoogleOAuthProvider>
</body>
</html>
);
}
4. Login page
app/login/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { GoogleLogin } from "@react-oauth/google";
import { useAuth } from "@/lib/auth-context";
export default function LoginPage() {
const { login, loginWithGoogle } = useAuth();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
await login(email, password);
router.push("/dashboard");
} catch {
setError("Invalid email or password.");
}
}
return (
<div style={{ maxWidth: 360, margin: "80px auto" }}>
<h1>Log in</h1>
<form onSubmit={handleSubmit}>
<input type="email" placeholder="Email" value={email}
onChange={(e) => setEmail(e.target.value)} required />
<input type="password" placeholder="Password" value={password}
onChange={(e) => setPassword(e.target.value)} required />
{error && <p style={{ color: "crimson" }}>{error}</p>}
<button type="submit">Log in</button>
</form>
<div style={{ margin: "16px 0" }}>or</div>
<GoogleLogin
onSuccess={async (cred) => {
if (!cred.credential) return;
await loginWithGoogle(cred.credential);
router.push("/dashboard");
}}
onError={() => setError("Google login failed.")}
/>
</div>
);
}
5. Signup page
app/signup/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
export default function SignupPage() {
const { signup } = useAuth();
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", displayName: "" });
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
await signup(form.email, form.password, form.displayName);
router.push("/dashboard");
} catch {
setError("Could not create account — email may already be in use.");
}
}
return (
<div style={{ maxWidth: 360, margin: "80px auto" }}>
<h1>Create account</h1>
<form onSubmit={handleSubmit}>
<input placeholder="Display name" value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })} required />
<input type="email" placeholder="Email" value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })} required />
<input type="password" placeholder="Password" value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} required />
{error && <p style={{ color: "crimson" }}>{error}</p>}
<button type="submit">Sign up</button>
</form>
</div>
);
}
6. Dashboard page
app/dashboard/page.tsx
"use client";
import { useAuth } from "@/lib/auth-context";
import { useRouter } from "next/navigation";
export default function DashboardPage() {
const { user, loading, logout } = useAuth();
const router = useRouter();
if (loading) return <p>Loading...</p>;
if (!user) {
router.push("/login");
return null;
}
return (
<div style={{ maxWidth: 480, margin: "80px auto" }}>
<h1>Welcome, {user.name}</h1>
<p>{user.email}</p>
<button
onClick={async () => {
await logout();
router.push("/login");
}}
>
Log out
</button>
</div>
);
}
7. Route protection with middleware
Client-side checks (if (!user) router.push(...)) flash the protected page for a split second before redirecting. middleware.ts stops that at the edge, before the page even renders — checking for the presence of the refresh cookie:
middleware.ts (project root)
import { NextRequest, NextResponse } from "next/server";
const PROTECTED_PATHS = ["/dashboard"];
export function middleware(request: NextRequest) {
const hasSession = request.cookies.has("refreshToken");
const isProtected = PROTECTED_PATHS.some((path) =>
request.nextUrl.pathname.startsWith(path)
);
if (isProtected && !hasSession) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
This only checks that a session cookie exists — it doesn't verify the JWT (middleware runs on the edge and can't easily talk to your DB or verify signatures cheaply on every request). The real authorization check still happens server-side via [Authorize] on the API. Middleware here is a UX guard, not your security boundary — don't skip the backend check.
Practical checklist before you ship this
-
CORS:
AllowCredentials()+withCredentials: truemust both be set, or the refresh cookie silently never arrives. -
SameSite cookies: use
SameSite=None; Secureonly if frontend and API are on different domains (requires HTTPS everywhere, including local dev via a tool likemkcert). If both share a domain (e.g.api.yourapp.com+yourapp.com),SameSite=Laxis simpler and safer. -
Google Console setup: register
http://localhost:3000as an authorized JavaScript origin for local dev, and your real domain for production. -
Secrets:
dotnet user-secrets set "Jwt:AccessTokenKey" "..."locally; environment variables in production — never inappsettings.jsoncommitted to git. - Token expiry tuning: 15 min access / 7 day refresh is a reasonable starting point; tighten for anything handling sensitive data.
-
Rate limit
/loginand/registerin production — this guide doesn't include it, but brute-force protection matters.








