"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:
- Input validation that rejects garbage
- Email verification
- Forgot/reset password
- Refresh token theft detection (not just rotation)
- Rate limiting on login/register
- Role-based authorization
- CSRF protection on cookie-based endpoints
- "Log out everywhere" / session revocation
- Global exception handling
- 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
);
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!;
}
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;
}
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." });
}
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);
}
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;
}
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." });
}
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; }
}
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();
}
_
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();
Apply it to the sensitive endpoints:
[HttpPost("login")]
[EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> Login(LoginRequest req) { /* ... */ }
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"
}
Include it as a claim when issuing the access token, in TokenService.GenerateAccessToken:
new Claim(ClaimTypes.Role, user.Role)
Now any endpoint can gate by role natively:
[Authorize(Roles = "Admin")]
[HttpGet("admin/users")]
public async Task<IActionResult> GetAllUsers() { /* ... */ }
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();
}
[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();
}
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
});
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();
});
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." });
});
});
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
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>
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)