DEV Community

Cover image for Full Authentication in .NET 8 Web API + Next.js: JWT, Google OAuth, and a Protected Dashboard (Complete Guide, Part-2)
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-2)

"Hardening a .NET + Next.js Auth System: Email Verification, Password Reset, Token Theft Detection, Roles, and Rate Limiting"

This is Part 2 of the auth series. Part 1 built the actual skeleton: JWT access tokens, httpOnly refresh cookies, Google OAuth, and a protected dashboard. That's the part every tutorial shows.

This post covers the part almost none of them show — the stuff that separates a demo from something you'd actually put in front of users:

  1. Input validation that rejects garbage
  2. Email verification
  3. Forgot/reset password
  4. Refresh token theft detection (not just rotation)
  5. Rate limiting on login/register
  6. Role-based authorization
  7. CSRF protection on cookie-based endpoints
  8. "Log out everywhere" / session revocation
  9. Global exception handling
  10. Frontend form validation with react-hook-form + zod

Same repo, same structure — we're extending backend/ and frontend/ from Part 1.


1. Input validation

Right now Register accepts a one-character password. Fix it at the DTO level so [ApiController] auto-returns 400 before your logic even runs.

Controllers/AuthController.cs — update the records:

using System.ComponentModel.DataAnnotations;

public record RegisterRequest(
    [property: Required, EmailAddress] string Email,
    [property: Required, MinLength(8)] string Password,
    [property: Required, MinLength(2)] string DisplayName
);

public record LoginRequest(
    [property: Required, EmailAddress] string Email,
    [property: Required] string Password
);
Enter fullscreen mode Exit fullscreen mode

That's it — [ApiController] model validation kicks in automatically and returns a structured 400 with field errors. No controller code needed.


2. Email verification

New model — Models/EmailVerificationToken.cs:

namespace AuthDemo.Api.Models;

public class EmailVerificationToken
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string TokenHash { get; set; } = default!;
    public DateTime ExpiresAt { get; set; }
    public Guid UserId { get; set; }
    public User User { get; set; } = default!;
}
Enter fullscreen mode Exit fullscreen mode

Add public bool EmailVerified { get; set; } = false; to User.

AuthService.cs — add:

public async Task<string> IssueEmailVerificationTokenAsync(Guid userId)
{
    var raw = _tokens.GenerateRawRefreshToken(); // reuse the same secure random generator
    _db.Add(new EmailVerificationToken
    {
        UserId = userId,
        TokenHash = _tokens.HashToken(raw),
        ExpiresAt = DateTime.UtcNow.AddHours(24)
    });
    await _db.SaveChangesAsync();
    return raw;
}

public async Task<bool> VerifyEmailAsync(Guid userId, string rawToken)
{
    var tokens = await _db.Set<EmailVerificationToken>()
        .Where(t => t.UserId == userId && t.ExpiresAt > DateTime.UtcNow)
        .ToListAsync();

    var match = tokens.FirstOrDefault(t => _tokens.VerifyTokenHash(rawToken, t.TokenHash));
    if (match is null) return false;

    var user = await _db.Users.FindAsync(userId);
    user!.EmailVerified = true;
    _db.Remove(match);
    await _db.SaveChangesAsync();
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Controller additions:

[HttpGet("verify-email")]
public async Task<IActionResult> VerifyEmail([FromQuery] Guid userId, [FromQuery] string token)
{
    var ok = await _auth.VerifyEmailAsync(userId, token);
    return ok ? Ok(new { message = "Email verified." }) : BadRequest(new { message = "Invalid or expired token." });
}
Enter fullscreen mode Exit fullscreen mode

Call IssueEmailVerificationTokenAsync right after registration and email the link (https://yourapp.com/verify?userId={id}&token={raw}) via your provider of choice — SendGrid, Postmark, AWS SES. Wire that up with an IEmailSender interface so you can swap providers without touching auth logic:

public interface IEmailSender
{
    Task SendAsync(string toEmail, string subject, string htmlBody);
}
Enter fullscreen mode Exit fullscreen mode

Inject and call it in RegisterAsync. For local dev, a ConsoleEmailSender that just logs the link is enough to build against.

Enforce it: gate sensitive actions behind EmailVerified — either in the [Authorize] policy or a manual check in controllers that matter.


3. Forgot / reset password

Same shape as email verification, separate token type so a leaked reset link can't double as an email-verification bypass.

Models/PasswordResetToken.cs — identical shape to EmailVerificationToken.

public async Task<string?> IssuePasswordResetTokenAsync(string email)
{
    var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
    if (user is null) return null; // caller returns 200 anyway — see note below

    var raw = _tokens.GenerateRawRefreshToken();
    _db.Add(new PasswordResetToken
    {
        UserId = user.Id,
        TokenHash = _tokens.HashToken(raw),
        ExpiresAt = DateTime.UtcNow.AddMinutes(30)
    });
    await _db.SaveChangesAsync();
    return raw;
}

public async Task<bool> ResetPasswordAsync(string email, string rawToken, string newPassword)
{
    var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
    if (user is null) return false;

    var tokens = await _db.Set<PasswordResetToken>()
        .Where(t => t.UserId == user.Id && t.ExpiresAt > DateTime.UtcNow)
        .ToListAsync();

    var match = tokens.FirstOrDefault(t => _tokens.VerifyTokenHash(rawToken, t.TokenHash));
    if (match is null) return false;

    user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
    _db.Remove(match);
    await RevokeAllRefreshTokensAsync(user.Id); // force re-login everywhere — see section 7
    await _db.SaveChangesAsync();
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Important controller detail:

[HttpPost("forgot-password")]
public async Task<IActionResult> ForgotPassword([FromBody] string email)
{
    var token = await _auth.IssuePasswordResetTokenAsync(email);
    if (token is not null)
    {
        // send email with reset link containing `token`
    }
    // ALWAYS return 200, regardless of whether the email existed.
    return Ok(new { message = "If that email exists, a reset link has been sent." });
}
Enter fullscreen mode Exit fullscreen mode

Returning a different response for "email not found" vs "email found" lets attackers enumerate registered accounts. Always respond identically.


4. Refresh token theft detection (not just rotation)

Part 1 rotated tokens (one-time use) but didn't detect reuse. Reuse of an already-revoked refresh token is the actual signal of theft — it means someone has a copy of a token you already replaced.

Add a FamilyId to RefreshToken — every refresh token issued during one login session shares a family:

public class RefreshToken
{
    // ...existing fields
    public Guid FamilyId { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Update issuance to carry the family forward, and detect reuse:

public async Task<(string raw, RefreshToken entity)> IssueRefreshTokenAsync(Guid userId, Guid? familyId = null)
{
    var raw = _tokens.GenerateRawRefreshToken();
    var entity = new RefreshToken
    {
        UserId = userId,
        TokenHash = _tokens.HashToken(raw),
        FamilyId = familyId ?? Guid.NewGuid(), // new family on login, same family on refresh
        ExpiresAt = DateTime.UtcNow.AddDays(7)
    };
    _db.RefreshTokens.Add(entity);
    await _db.SaveChangesAsync();
    return (raw, entity);
}

public async Task<User?> ValidateRefreshTokenAsync(string rawToken, out Guid? reusedFamilyId)
{
    reusedFamilyId = null;
    var all = await _db.RefreshTokens.Include(rt => rt.User).ToListAsync();
    var match = all.FirstOrDefault(rt => _tokens.VerifyTokenHash(rawToken, rt.TokenHash));

    if (match is null) return null;

    if (match.Revoked)
    {
        // This exact token was already used once before — theft signal.
        reusedFamilyId = match.FamilyId;
        await RevokeFamilyAsync(match.FamilyId); // kill every token in the family
        return null;
    }

    if (match.ExpiresAt <= DateTime.UtcNow) return null;

    match.Revoked = true;
    await _db.SaveChangesAsync();
    return match.User;
}

public async Task RevokeFamilyAsync(Guid familyId)
{
    var tokens = await _db.RefreshTokens.Where(rt => rt.FamilyId == familyId && !rt.Revoked).ToListAsync();
    tokens.ForEach(t => t.Revoked = true);
    await _db.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

_
In the controller's /refresh endpoint, when reusedFamilyId comes back non-null, that whole session is dead — force logout and, ideally, notify the user by email that a suspicious refresh attempt was blocked.

_

5. Rate limiting

.NET 8 has built-in rate limiting — no extra package needed.

Program.cs:

using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("AuthPolicy", opt =>
    {
        opt.PermitLimit = 5;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 0;
    });
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

// after building the app:
app.UseRateLimiter();
Enter fullscreen mode Exit fullscreen mode

Apply it to the sensitive endpoints:

[HttpPost("login")]
[EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> Login(LoginRequest req) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Do the same for register and forgot-password. Five attempts per minute per policy instance is a reasonable starting point — tune based on real traffic, and consider keying the limiter by IP + email combo instead of globally if you have Microsoft.AspNetCore.RateLimiting's partitioned limiter available.


6. Role-based authorization

Add a role field:

public class User
{
    // ...existing fields
    public string Role { get; set; } = "User"; // "User" | "Admin"
}
Enter fullscreen mode Exit fullscreen mode

Include it as a claim when issuing the access token, in TokenService.GenerateAccessToken:

new Claim(ClaimTypes.Role, user.Role)
Enter fullscreen mode Exit fullscreen mode

Now any endpoint can gate by role natively:

[Authorize(Roles = "Admin")]
[HttpGet("admin/users")]
public async Task<IActionResult> GetAllUsers() { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

For anything more granular than two roles (e.g. per-resource permissions), look at ASP.NET Core's policy-based authorization instead of stacking role checks — but for most apps, a Role string claim is genuinely enough.


7. Log out everywhere

Useful after a password change, or as a user-facing "sign out of all devices" button.

public async Task RevokeAllRefreshTokensAsync(Guid userId)
{
    var tokens = await _db.RefreshTokens.Where(rt => rt.UserId == userId && !rt.Revoked).ToListAsync();
    tokens.ForEach(t => t.Revoked = true);
    await _db.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode
[Authorize]
[HttpPost("logout-all")]
public async Task<IActionResult> LogoutAll()
{
    var userId = Guid.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
    await _auth.RevokeAllRefreshTokensAsync(userId);
    Response.Cookies.Delete("refreshToken");
    return Ok();
}
Enter fullscreen mode Exit fullscreen mode

We already call this from ResetPasswordAsync in section 3 — a password reset should always kill every existing session.


8. CSRF protection on cookie endpoints

Your API is Bearer-token authenticated for normal requests, so most endpoints aren't CSRF-exposed — the token lives in memory, not a cookie a browser would auto-attach. But /refresh and /logout rely purely on the cookie, which is auto-attached by the browser, including cross-site.

The standard fix is the double-submit cookie pattern: a second, non-httpOnly cookie holding a CSRF token that JS reads and sends back as a header, which a malicious site can't replicate.

// in IssueSession, alongside the refreshToken cookie:
var csrfToken = Guid.NewGuid().ToString("N");
Response.Cookies.Append("csrfToken", csrfToken, new CookieOptions
{
    HttpOnly = false, // JS needs to read this one
    Secure = true,
    SameSite = SameSiteMode.Strict
});
Enter fullscreen mode Exit fullscreen mode

Add middleware that checks the X-CSRF-Token header matches the csrfToken cookie on /refresh and /logout:

app.Use(async (context, next) =>
{
    var path = context.Request.Path;
    if (path == "/api/auth/refresh" || path == "/api/auth/logout")
    {
        var cookieToken = context.Request.Cookies["csrfToken"];
        var headerToken = context.Request.Headers["X-CSRF-Token"].ToString();
        if (string.IsNullOrEmpty(cookieToken) || cookieToken != headerToken)
        {
            context.Response.StatusCode = 403;
            return;
        }
    }
    await next();
});
Enter fullscreen mode Exit fullscreen mode

Frontend: read the csrfToken cookie and attach it as a header in lib/api.ts's request interceptor for those two calls.


9. Global exception handling

Right now, RegisterAsync throwing InvalidOperationException leaks a raw 500 with a stack trace. Fix with exception-handling middleware:

Program.cs:

app.UseExceptionHandler(errApp =>
{
    errApp.Run(async context =>
    {
        var feature = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
        var ex = feature?.Error;

        context.Response.ContentType = "application/json";
        context.Response.StatusCode = ex switch
        {
            InvalidOperationException => StatusCodes.Status400BadRequest,
            UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
            _ => StatusCodes.Status500InternalServerError
        };

        await context.Response.WriteAsJsonAsync(new { message = ex?.Message ?? "An unexpected error occurred." });
    });
});
Enter fullscreen mode Exit fullscreen mode

Place this as the first middleware registered, before UseCors/UseAuthentication. Never expose raw exception messages for unhandled 500s in production — swap the message for a generic one and log the real exception server-side instead.


10. Frontend: real form validation

Part 1's forms had no client-side validation. Add react-hook-form + zod:

npm install react-hook-form zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

app/signup/page.tsx (relevant parts):

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const signupSchema = z.object({
  displayName: z.string().min(2, "Name is too short"),
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

type SignupForm = z.infer<typeof signupSchema>;

// inside the component:
const { register, handleSubmit, formState: { errors, isSubmitting } } =
  useForm<SignupForm>({ resolver: zodResolver(signupSchema) });

async function onSubmit(data: SignupForm) {
  await signup(data.email, data.password, data.displayName);
  router.push("/dashboard");
}

// in JSX:
<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register("displayName")} placeholder="Display name" />
  {errors.displayName && <p>{errors.displayName.message}</p>}
  {/* same pattern for email, password, confirmPassword */}
  <button type="submit" disabled={isSubmitting}>
    {isSubmitting ? "Creating account..." : "Sign up"}
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

Same pattern applies to the login form (simpler schema — just email + non-empty password) and the new forgot/reset-password pages you'll need to add to mirror the backend endpoints from sections 2–3.


What's covered now

Stacking this on top of Part 1, the system now handles: validated input, email verification, password reset with session invalidation, refresh token theft detection with full-family revocation, rate-limited auth endpoints, role-based authorization, CSRF-protected cookie endpoints, clean error responses, and real client-side validation.

That's genuinely enough to take into a real product — not a toy.


End note

Two posts, one working system: Part 1 gave you the actual mechanics — JWT access tokens, httpOnly refresh cookies, Google OAuth, and a properly protected dashboard. Part 2 took it from "demo that technically works" to "system that survives contact with real users and real attackers."

A few things intentionally stayed out of scope, worth knowing about as your next steps if you keep building on this:

  • Multi-factor authentication (TOTP/authenticator apps) — a natural next layer once you have solid password + OAuth flows.
  • WebAuthn / passkeys — increasingly the default for new products; a bigger lift than anything here, but worth it long-term.
  • Multi-tenancy — if this ever needs to serve organizations rather than individual users, roles alone won't be enough; you'd need an org/membership model.
  • Audit logging — who logged in when, from where, failed attempts — valuable once you have real users to protect.

None of that changes what's already here — the core is sound and the extensions in this post are the ones almost every production auth system actually needs. If you build from this, you're not copying a toy tutorial; you're starting from something structurally correct.

Thanks for following along — if this helped, a follow and a comment on what you'd want covered next (MFA? passkeys? multi-tenancy?) genuinely helps shape where the series goes.

Top comments (0)