DEV Community

Cover image for Full Authentication in .NET 8 Web API + Next.js: JWT, Google OAuth, and a Protected Dashboard (Complete Guide, Part-1)
Bhadra Mohit
Bhadra Mohit

Posted on

Full Authentication in .NET 8 Web API + Next.js: JWT, Google OAuth, and a Protected Dashboard (Complete Guide, Part-1)

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 useEffect check

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
Enter fullscreen mode Exit fullscreen mode

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 /refresh on 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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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!;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 } });
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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*"],
};
Enter fullscreen mode Exit fullscreen mode

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: true must both be set, or the refresh cookie silently never arrives.
  • SameSite cookies: use SameSite=None; Secure only if frontend and API are on different domains (requires HTTPS everywhere, including local dev via a tool like mkcert). If both share a domain (e.g. api.yourapp.com + yourapp.com), SameSite=Lax is simpler and safer.
  • Google Console setup: register http://localhost:3000 as 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 in appsettings.json committed to git.
  • Token expiry tuning: 15 min access / 7 day refresh is a reasonable starting point; tighten for anything handling sensitive data.
  • Rate limit /login and /register in production — this guide doesn't include it, but brute-force protection matters.

Top comments (0)