The Problem
API authentication is deceptively complex. The basics \u2014 issuing a JWT token on login \u2014 seem straightforward enough. But production-grade authentication requires much more: token refresh flows, secure token storage guidance for clients, proper expiration policies, revocation support, and security headers that prevent common attacks.
Most tutorials cover the happy path and stop there. They show you how to generate a JWT but skip token refresh, ignore HTTPS enforcement, forget security headers, and use hardcoded secrets that end up in source control. The result is an API that looks secure on the surface but has critical gaps that attackers exploit routinely.
You need a complete, production-ready authentication implementation that handles the full lifecycle: registration, login, token issuance, refresh, and proper security hardening.
Ingredients
Microsoft.AspNetCore.Authentication.JwtBearer
vlatest
JWT bearer authentication for ASP.NET Core
BCrypt.Net-Next
vlatest
BCrypt password hashing library
Noundry.Guardian
vlatest
Input validation guard clauses
Noundry.Tuxedo
vlatest
Data access with Dapper extensions
This recipe uses the built-in .NET authentication and JWT libraries, combined with BCrypt for password hashing and Guardian for input validation. No third-party authentication services required \u2014 everything runs in your own infrastructure.
The combination of these packages gives you complete control over your authentication flow without depending on external identity providers.
Preparation
Install the required packages:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package BCrypt.Net-Next
dotnet add package Noundry.Guardian
dotnet add package Noundry.Tuxedo
Configure JWT authentication in your Program.cs by setting up the authentication scheme, token validation parameters, and security headers. Store your JWT signing key in environment variables or a secrets manager \u2014 never in source control.
Make sure to configure HTTPS redirection, HSTS headers, and CORS policies before adding authentication to your middleware pipeline. The order of middleware registration matters for security.
The Code
Here's a complete authentication service that handles user registration with BCrypt password hashing, JWT token generation with configurable expiration, and a refresh token flow for seamless session management.
using System.Data;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Noundry.Guardian;
using Noundry.Tuxedo.Contrib;
using Noundry.Tuxedo.QueryBuilder;
namespace MyApp.Services;
public class AuthService
{
private readonly IDbConnection _connection;
private readonly IConfiguration _config;
public AuthService(IDbConnection connection, IConfiguration config)
{
_connection = Guard.Against.Null(connection);
_config = Guard.Against.Null(config);
}
public async Task<AuthResult> RegisterAsync(string email, string password, string name)
{
Guard.Against.NullOrWhiteSpace(email);
Guard.Against.NullOrWhiteSpace(password);
Guard.Against.NullOrWhiteSpace(name);
// Check for existing user
var existing = await new QueryBuilder<User>()
.SelectAll()
.Where(u => u.Email == email)
.ToListAsync(_connection);
if (existing.Any())
return AuthResult.Fail("Email already registered");
var user = new User
{
Id = Guid.NewGuid(),
Email = email.ToLowerInvariant(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12),
Name = name,
CreatedAt = DateTime.UtcNow
};
await _connection.InsertAsync(user);
var tokens = GenerateTokens(user);
return AuthResult.Success(tokens.AccessToken, tokens.RefreshToken);
}
public async Task<AuthResult> LoginAsync(string email, string password)
{
Guard.Against.NullOrWhiteSpace(email);
Guard.Against.NullOrWhiteSpace(password);
var users = await new QueryBuilder<User>()
.SelectAll()
.Where(u => u.Email == email.ToLowerInvariant())
.ToListAsync(_connection);
var user = users.FirstOrDefault();
if (user == null || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return AuthResult.Fail("Invalid email or password");
var tokens = GenerateTokens(user);
return AuthResult.Success(tokens.AccessToken, tokens.RefreshToken);
}
private (string AccessToken, string RefreshToken) GenerateTokens(User user)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:SigningKey"]!));
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Name),
new Claim("jti", Guid.NewGuid().ToString())
};
var accessToken = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
var refreshToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
return (new JwtSecurityTokenHandler().WriteToken(accessToken), refreshToken);
}
}
Plating
Production API authentication is a solved problem when you follow the right patterns. BCrypt handles password hashing with configurable work factors that grow with Moore's Law. Short-lived JWTs combined with refresh tokens provide a secure session model that limits the blast radius of token theft.
The key takeaways from this recipe: never store passwords in plaintext, always use a high work factor for BCrypt, keep access tokens short-lived (15 minutes or less), use refresh tokens for session continuity, and always transmit tokens over HTTPS.
By combining these practices with Guardian's input validation and Tuxedo's clean data access, you get an authentication layer that's secure by default and easy to reason about. No magic frameworks, no third-party dependencies you can't audit \u2014 just straightforward, testable code that follows industry best practices.
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.