Welcome to this deep dive into JWT authentication.
Whether you’re a beginner just dipping your toes into authentication or a seasoned developer looking to solidify your understanding, this article is crafted for you.
If you stick with me until the end, I promise you’ll gain a solid understanding of JWT, authorization, registration, and how authentication services work.
We’ll start with the basics, build a complete mini-project, and cover every nook and cranny. common pitfalls, pro tips, and all.
By the end, you’ll have a working authentication service you can proudly bookmark and refer to anytime.
Let’s roll up our sleeves and get started!
What is JWT Authentication?
Let’s start at the beginning. JWT stands for JSON Web Token, and it’s a compact, self-contained way to represent information between two parties—typically a client (like a browser or mobile app) and a server.
It's like a digital passport: it contains information about you, it’s signed to prove it’s legit, and it can be verified by anyone who knows the secret.
In authentication, JWTs are used to verify a user’s identity. Here’s how it works at a high level:
- A user logs in with their credentials (e.g., username and password).
- The server verifies the credentials and, if valid, generates a JWT.
- The JWT is sent back to the client, which stores it (usually in local storage or a cookie).
- For subsequent requests, the client includes the JWT in the request (typically in the
Authorization
header). - The server validates the JWT and, if it’s valid, processes the request.
What makes JWT special is that it’s stateless. That means that the server doesn’t need to store session data; everything needed to verify the user is encoded in the token itself. This makes it scalable and perfect for modern APIs.
Why Use JWT?
You might be wondering, “Why not just use sessions or another method?” Great question!
Here’s why JWT is a popular choice:
- Statelessness: No need to store session data on the server, which simplifies scaling across multiple servers.
- Cross-Platform: JWTs work across different platforms and languages: your C# backend can issue a token that a JavaScript frontend or Python client can use.
- Self-Contained: The token carries user information (like a user ID or role), reducing database lookups.
- Secure: When properly implemented, JWTs are signed to prevent tampering and can be encrypted for confidentiality.
But it’s not all roses. JWTs have trade-offs:
- Size: They can be larger than session IDs, increasing request overhead.
- Revocation: Once issued, a JWT is valid until it expires unless you implement a revocation mechanism (we’ll cover this later).
- Security Risks: Misconfigurations (like weak signing keys) can lead to vulnerabilities.
We’ll address these challenges as we build our project, so you’ll know exactly how to use JWTs safely.
Understanding the Components of JWT
A JWT looks like a random string, but it’s actually three parts separated by dots (.
). For example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Let’s break it down:
- Header: The first part (
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
) is Base64-encoded JSON that describes the algorithm used to sign the token (e.g., HMAC-SHA256) and the token type (JWT). - Payload: The second part (
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
) is Base64-encoded JSON containing claims, meaning, data about the user, like their ID, name, or roles. Standard claims include:sub
(subject): The user ID.iat
(issued at): When the token was created.exp
(expiration): When the token expires. You can also add custom claims, likerole
oremail
.
- Signature: The third part (
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
) is a signature created by combining the encoded header, payload, and a secret key using the algorithm specified in the header. It ensures the token hasn’t been tampered with.
When you decode a JWT, you get something like this:
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
The signature is what makes JWT secure.
If someone changes the payload (say, to give themselves admin rights), the signature won’t match, and the server will reject the token.
Our Mini-Project: The Auth Service
Now, let’s get our hands dirty with a real project!
We’re going to build a simple authentication service in C# using ASP.NET Core. Here’s what it’ll do:
- Allow users to register with a username and password.
- Let users log in and receive a JWT access token and a refresh token.
- Protect certain endpoints so only authenticated users can access them.
- Allow users to refresh their access token using a refresh token.
- Support invalidating refresh tokens for security.
- Be testable with Postman.
This project will be minimal but complete, giving you a solid foundation to build upon. You could even use it as your authentication service for hobby/toy projects.
Project Overview
Our service will have the following endpoints:
POST /api/auth/register
: Register a new user.POST /api/auth/login
: Log in and receive an access token and refresh token.POST /api/auth/refresh
: Use a refresh token to get a new access token.POST /api/auth/revoke
: Invalidate a refresh token.GET /api/protected
: A protected endpoint that requires a valid JWT.
We’ll store users and refresh tokens in an in-memory database (for simplicity), but you can swap it out for PostgreSQL or SQL Server, or any database in a real app.
Setting Up the Environment
Let’s set up our project.
-
Open your favority IDE and create a new WebAPI project. I'll call it
JwtAuthDemo
-
Add Required Packages: We’ll need libraries for JWT, password hashing, and an in-memory database. Add these NuGet packages (you can use the NuGet manager from your IDE or just add the packages from the terminal):
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer dotnet add package System.IdentityModel.Tokens.Jwt dotnet add package Microsoft.EntityFrameworkCore.InMemory dotnet add package BCrypt.Net-Next
-
Project Structure: Here’s how we’ll organize our code. You can go ahead and create the directories and classes:
JwtAuthDemo/ ├── Controllers/ │ └── AuthController.cs ├── Models/ │ ├── LoginRequest.cs │ ├── RefreshToken.cs │ ├── RefreshTokenRequest.cs │ ├── RegisterRequest.cs │ ├── TokenRequest.cs │ ├── TokenResponse.cs │ ├── User.cs ├── Data/ │ └── AppDbContext.cs ├── Services/ │ └── AuthService.cs ├── Program.cs ├── appsettings.json
You'll get the content and explanation of each class as we go.
-
Configure
appsettings.json
: Add your JWT settings toappsettings.json
:{ "Jwt": { "Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong", "Issuer": "JwtAuthDemo", "Audience": "JwtAuthDemo", "AccessTokenExpirationMinutes": 15, "RefreshTokenExpirationDays": 7 }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" }
Pro Tip: In a real app, store the
Key
in a secure place like Azure Key Vault or environment variables, not inappsettings.json
. For this demo, we’re keeping it simple.
Step 1: User Registration
Let’s start by allowing users to register. We’ll need a model for users, a database context, and an endpoint to handle registration.
Models
This is a simple model for the User
entity (Models/User.cs
):
namespace JwtAuthDemo.Models;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
}
We need to setup the format of the request Models/RegisterRequest.cs
:
namespace JwtAuthDemo.Models;
public class RegisterRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
Database Context
Following the database context in the class Data/AppDbContext.cs
:
using JwtAuthDemo.Models;
using Microsoft.EntityFrameworkCore;
namespace JwtAuthDemo.Data;
public class AppDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
}
Configure Dependency Injection
Update Program.cs
to set up our in-memory database:
using JwtAuthDemo.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("JwtAuthDemoDb"));
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Auth Service
Here, we'll handle the registration logic. Update Services/AuthService.cs
:
using JwtAuthDemo.Data;
using JwtAuthDemo.Models;
namespace JwtAuthDemo.Services;
public class AuthService
{
private readonly AppDbContext _context;
public AuthService(AppDbContext context)
{
_context = context;
}
public async Task<User?> RegisterAsync(RegisterRequest request)
{
if (_context.Users.Any(u => u.Username == request.Username))
{
return null; // User already exists
}
var user = new User
{
Username = request.Username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
}
Gotcha: We're using BCrypt
to hash passwords. Never store plain-text passwords! BCrypt
is secure and handles salting for you.
Register the service in Program.cs
:
builder.Services.AddScoped<AuthService>();
You'll need to add the reference to the services namespace (using JwtAuthDemo.Services;
at the top)
Auth Controller
Let's update Controllers/AuthController.cs
:
using JwtAuthDemo.Models;
using JwtAuthDemo.Services;
using Microsoft.AspNetCore.Mvc;
namespace JwtAuthDemo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly AuthService _authService;
public AuthController(AuthService authService)
{
_authService = authService;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var user = await _authService.RegisterAsync(request);
if (user == null)
{
return BadRequest("Username already exists.");
}
return Ok(new { user.Id, user.Username });
}
}
Common Error: Forgetting to validate input. In a production app, add checks for password strength, username format, etc., using something like FluentValidation.
Step 2: Login and JWT Generation
Now, let's allow users to log in and receive a JWT. Along with the JWT access token, we'll generate a refresh token.
Token Response Model
Update Models/TokenResponse.cs
:
namespace JwtAuthDemo.Models;
public class TokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
}
Login Request Model
Update Models/LoginRequest.cs
:
namespace JwtAuthDemo.Models;
public class LoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
Refresh Token Model
Update Models/RefreshToken.cs
as follows:
namespace JwtAuthDemo.Models;
public class RefreshToken
{
public int Id { get; set; }
public string Token { get; set; } = string.Empty;
public int UserId { get; set; }
public DateTime Expires { get; set; }
public bool IsRevoked { get; set; }
}
Configure JWT in Program.cs
Add JWT authentication to Program.cs
:
using JwtAuthDemo.Data;
using JwtAuthDemo.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("JwtAuthDemoDb"));
builder.Services.AddScoped<AuthService>();
// Configure JWT Authentication
var jwtSettings = builder.Configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwtSettings["Key"]!);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(key)
};
});
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Pro Tip: Use a symmetric key (like above) for simplicity, but in production, consider asymmetric keys (RSA) for better security.
Update AuthService for Login
Let's update AuthService.cs
to add the login and token generation:
using JwtAuthDemo.Data;
using JwtAuthDemo.Models;
using Microsoft.EntityFrameworkCore;
using BCrypt.Net;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
namespace JwtAuthDemo.Services;
public class AuthService
{
private readonly AppDbContext _context;
private readonly IConfiguration _configuration;
public AuthService(AppDbContext context, IConfiguration configuration)
{
_context = context;
_configuration = configuration;
}
public async Task<User?> RegisterAsync(RegisterRequest request)
{
// I won't repeat the code, this method stays the same
}
public async Task<TokenResponse?> LoginAsync(LoginRequest request)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Username == request.Username);
if (user == null ||
!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
return null; // Invalid credentials
}
var accessToken = GenerateAccessToken(user);
var refreshToken = await GenerateRefreshTokenAsync(user);
return new TokenResponse
{
AccessToken = accessToken,
RefreshToken = refreshToken.Token
};
}
private string GenerateAccessToken(User user)
{
var jwtSettings = _configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwtSettings["Key"]!);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Name, user.Username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
};
var creds = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: jwtSettings["Issuer"],
audience: jwtSettings["Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(double.Parse(jwtSettings["AccessTokenExpirationMinutes"]!)),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<RefreshToken> GenerateRefreshTokenAsync(User user)
{
var refreshToken = new RefreshToken
{
Token = Guid.NewGuid().ToString(),
UserId = user.Id,
Expires = DateTime.UtcNow.AddDays(
double.Parse(_configuration["Jwt:RefreshTokenExpirationDays"]!))
};
_context.RefreshTokens.Add(refreshToken);
await _context.SaveChangesAsync();
return refreshToken;
}
}
Update AuthController
Add the login endpoint to AuthController.cs
:
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var response = await _authService.LoginAsync(request);
if (response == null)
{
return Unauthorized("Invalid username or password.");
}
return Ok(response);
}
Common Error: Forgetting to set UseAuthentication()
before UseAuthorization()
in Program.cs
. The order matters!
Step 3: Refresh Tokens
Access tokens are short-lived (15 minutes in our case) for security. Refresh tokens are long-lived (7 days) and let users get new access tokens without re-entering credentials. That's why it's important to have a way to invalidate refresh tokens, but for access tokens, we know they will be effective for just some minutes (trade-off).
Update AuthService
Let's add a method to handle refresh tokens in AuthService.cs
:
public async Task<TokenResponse?> RefreshTokenAsync(string refreshToken)
{
var token = await _context.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == refreshToken);
if (token == null || token.IsRevoked || token.Expires < DateTime.UtcNow)
{
return null; // Invalid or expired token
}
var user = await _context.Users.FindAsync(token.UserId);
if (user == null)
{
return null; // User not found
}
// Generate new access token
var newAccessToken = GenerateAccessToken(user);
// Generate new refresh token and revoke the old one
token.IsRevoked = true;
var newRefreshToken = await GenerateRefreshTokenAsync(user);
await _context.SaveChangesAsync();
return new TokenResponse
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken.Token
};
}
Update AuthController
Let's define the model for the request of the refresh token in Models/RefreshTokenRequest.cs
:
namespace JwtAuthDemo.Models;
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}
Then, let's add the refresh endpoint in Controllers/AuthController
:
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
{
var response = await _authService.RefreshTokenAsync(request.RefreshToken);
if (response == null)
{
return Unauthorized("Invalid or expired refresh token.");
}
return Ok(response);
}
Gotcha: Always revoke the old refresh token when issuing a new one to prevent token reuse. This is called token rotation.
Step 4: Token Validation
For our test, let’s create a protected endpoint to test our JWT authentication. This endpoint will not have any logic, we will use it only to test that our access token is working.
In a microservices configuration, the authentication service will be, most likely, in a different service than the services using the access token.
Update Controllers/AuthController.cs
:
[HttpGet("protected")]
[Authorize]
public IActionResult Protected()
{
var username = User.Identity?.Name;
return Ok($"Hello, {username}! This is a protected endpoint.");
}
(You'll need to add a reference to Microsoft.AspNetCore.Authorization.AuthorizeAttribute
)
The [Authorize]
attribute ensures only users with a valid JWT (signed with the correct symmetric key, same issuer, audience, etc) can access this endpoint.
Pro Tip: You can add roles to claims and use [Authorize(Roles = "Admin")]
for role-based access control.
Step 5: Refresh Token Authentication
We've already implemented refresh token logic in Step 3. When a client's access token expires, they can send the refresh token to /api/auth/refresh
to get a new access token and refresh token.
Remember the flow:
- Your client app asks for the regular credentials (username/password)
- Sends them to our Auth service to get an access token (which goes with a refresh token)
- The access token can be used for all the upcoming requests, until it fails
- When the access token fails, your client app can ask the Auth service for a new one using the refresh token (so your users won't need to enter username/password every 15 minutes
- All new requests will be done using the new access token (until it fails) and the cycle is repeated
Step 6: Invalidating Refresh Tokens
As I said before, refresh tokens are long-lived, so we need to proctect our system with a mechanism to invalidate one (or all) refresh tokens. This is done when we suspect of any security breach or when the user signs out.
So, let's create an endpoint to revoke a refresh token.
Pro-tip: In a production app, role-based authentication is recommended. The endpoint to revoke refresh tokens, will be available to Admin users.
Update AuthService
Let's add a new method in our AuthService.cs
:
public async Task<bool> RevokeRefreshTokenAsync(string refreshToken)
{
var token = await _context.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == refreshToken);
if (token == null || token.IsRevoked)
{
return false;
}
token.IsRevoked = true;
await _context.SaveChangesAsync();
return true;
}
Update AuthController
And with the new method in our service, we create a new endpoint in our AuthController.cs
:
[HttpPost("revoke")]
public async Task<IActionResult> Revoke([FromBody] RefreshTokenRequest request)
{
var success = await _authService.RevokeRefreshTokenAsync(request.RefreshToken);
if (!success)
{
return BadRequest("Invalid or already revoked refresh token.");
}
return Ok("Refresh token revoked.");
}
Common Error: Forgetting to check IsRevoked
when validating refresh tokens. Always verify both expiration and revocation status.
Step 7: Testing
To test our new service, you can use a tool like Postman, or if you like terminals, you can make the requests with curl
.
In addition, I'll share the JwtAuthDemo.http
file.
-
Start the App:
-
Register a User:
-
Method: POST
-
URL:
http://localhost:5000/api/auth/register
-
Body (raw JSON):
{ "username": "testuser", "password": "Test@123" }
-
Response: Should return the user’s ID and username.
-
-
Log In:
-
Method: POST
-
URL:
http://localhost:5000/api/auth/login
-
Body:
{ "username": "testuser", "password": "Test@123" }
-
Response: You’ll get an
accessToken
andrefreshToken
.
-
-
Access Protected Endpoint:
- Method: GET
- URL:
http://localhost:5000/api/auth/protected
- Headers:
Authorization: Bearer <accessToken>
- Response: Should return a greeting with the username.
-
Refresh Token:
-
Method: POST
-
URL:
http://localhost:5000/api/auth/refresh
-
Body:
{ "refreshToken": "<refreshToken>" }
-
Response: New
accessToken
andrefreshToken
.
-
-
Revoke Refresh Token:
-
Method: POST
-
URL:
http://localhost:5000/api/auth/revoke
-
Body:
{ "refreshToken": "<refreshToken>" }
-
Response: Confirmation that the token was revoked.
-
-
Test Revoked Token: Try refreshing with the revoked token. You should get an unauthorized response.
As promised, here is the http file that I used to test the project:
@JwtAuthDemo_HostAddress = http://localhost:5000
### Register a new user
POST {{JwtAuthDemo_HostAddress}}/api/Auth/register
Content-Type: application/json
{
"username": "testuser",
"password": "Test@123"
}
### Login with existing credentials
POST {{JwtAuthDemo_HostAddress}}/api/Auth/login
Content-Type: application/json
{
"username": "testuser",
"password": "Test@123"
}
### Refresh the token
POST {{JwtAuthDemo_HostAddress}}/api/Auth/refresh
Content-Type: application/json
{
"refreshToken": "your-refresh-token-here"
}
### Revoke a refresh token
POST {{JwtAuthDemo_HostAddress}}/api/Auth/revoke
Content-Type: application/json
{
"refreshToken": "the-refresh-token-to-revoke"
}
### Access a protected endpoint
GET {{JwtAuthDemo_HostAddress}}/api/Auth/protected
Authorization: Bearer your-access-token-here
Pro Tip: If you used Postman, you can save your requests in a collection to reuse them later.
Common Errors and Gotchas
Here are some pitfalls to watch out for:
- Weak Signing Key: A short or predictable key makes your JWTs vulnerable to brute-force attacks. Use a key at least 32 characters long and store it securely.
- Not Validating All Parameters: Always validate
Issuer
,Audience
,Lifetime
, andSignature
. Skipping any of these can allow invalid tokens to pass. - Storing Tokens Insecurely: On the client side, avoid storing JWTs in
localStorage
due to XSS risks. Use HTTP-only cookies withSecure
andSameSite=Strict
flags. - Long-Lived Access Tokens: Keep access tokens short-lived (e.g., 15–60(max) minutes) and use refresh tokens for longer sessions.
- Forgetting Token Rotation: Without rotating refresh tokens, a stolen token can be reused until it expires. Always issue a new refresh token and revoke the old one.
- Clock Skew: Servers and clients may have slightly different times. JWT libraries handle small clock skew (e.g., 5 seconds), but large differences can cause validation failures.
Pro Tips for JWT Authentication
- Use HTTPS: Always use HTTPS to prevent man-in-the-middle attacks that could intercept tokens.
- Add Custom Claims: Include roles, permissions, or other metadata in the JWT payload to avoid database queries.
- Implement Blacklisting: For high-security apps, maintain a blacklist of revoked JWTs (despite the stateless nature) or use short-lived tokens.
- Monitor Token Usage: Log suspicious activity, like multiple failed refresh attempts, to detect potential attacks. Limiting the number of requests in a giving time is a good idea.
- Use a Library: Don’t roll your own JWT implementation. Libraries like
System.IdentityModel.Tokens.Jwt
are battle-tested and secure. - Test Edge Cases: Test scenarios like expired tokens, revoked tokens, and invalid signatures to ensure your app handles them gracefully.
Further Reading and Resources
- Official JWT Website: Decode tokens and explore libraries.
- ASP.NET Core Authentication Docs: Deep dive into authentication in .NET.
- OWASP JWT Cheat Sheet: Security best practices.
- BCrypt.Net Documentation: Learn more about password hashing.
- Postman Learning Center: Master API testing.
Recommended Tools and Services for Authentication
As you move beyond this mini-project and into real-world applications, you might want to leverage established tools and services to handle authentication.
Building your own auth system, as we did here, is great for learning and small projects, but for production apps, especially those with complex requirements or large user bases, using a dedicated solution can save time, improve security, and simplify maintenance.
Below, I’ve highlighted a mix of paid services and a highly-recommended open-source project that are widely trusted for authentication.
Paid Services
-
Auth0: Auth0 is a robust, developer-friendly identity platform that handles authentication, authorization, and user management out of the box. It supports JWTs, OAuth, OpenID Connect, and more, with features like single sign-on (SSO), multi-factor authentication (MFA), and social logins. Its intuitive dashboard and extensive SDKs (including for .NET) make integration a breeze. Auth0 offers a free tier for small projects, but its paid plans unlock advanced features like custom branding and higher usage limits. Perfect for teams who want a secure, scalable solution without reinventing the wheel. Check it out at auth0.com.
-
Okta: Okta is another enterprise-grade identity provider that excels at both workforce and customer authentication. It supports JWTs, role-based access control, and integrations with virtually any tech stack. Okta’s strength lies in its flexibility and compliance with standards like SOC 2 and GDPR, making it ideal for businesses with strict security needs. While it’s pricier than some alternatives, its reliability and support are top-notch. Visit okta.com to explore their offerings.
-
Firebase Authentication: If you’re building a web or mobile app and already using Google Cloud, Firebase Authentication is a fantastic choice. It supports JWT-based auth, email/password logins, anonymous logins, and social providers like Google and Facebook. It’s tightly integrated with other Firebase services, making it great for rapid development. The free tier is generous, but paid plans scale with your app’s growth. Learn more at firebase.google.com/products/authentication.
Open-Source Projects
- Keycloak (my number one choice): Keycloak is an open-source identity and access management solution backed by Red Hat. It’s incredibly powerful, supporting JWTs, OAuth 2.0, OpenID Connect, and SAML. You can deploy Keycloak on-premises or in the cloud, customize it to your needs, and integrate it with your C# apps using libraries like
IdentityModel
. It offers features like SSO, user federation, and MFA, all for free. The trade-off is that you’ll need to manage hosting and configuration yourself, which can be complex for beginners. Dive in at keycloak.org.
Mini-project GitHug repo
I've created a GitHub repo with this code if you prefer to fork/download it.
Conclusion
You've made it to the end!
If you followed along, you now have a fully functional authentication service that supports user registration, login, JWT-based authentication, refresh tokens, and token revocation. And hopefully, you now understand better each part of an auth service.
This project is a starting point. In a real-world app, you’d add input validation, rate limiting, logging, and a persistent database.
You might also integrate with identity providers like OAuth or OpenID Connect. But what you’ve built here is a solid foundation, a bookmark-worthy reference you can return to anytime.
I hope it was as fun for you as it was for me to write.
Authentication can feel daunting, but breaking it down step by step makes it manageable and even enjoyable.
Keep experimenting, keep learning, and don’t hesitate to reach out if you have questions!
Happy coding! ⚡