From Zero to JWT: Building a Token API MVP with .NET 8 and Azure Functions
How to ship a lean, serverless, and secure auth service without falling into the over-engineering trap.

You've just had a fantastic idea for a new product. The data model is designed, the business rules are crystal clear in your head. You open your IDE, ready to code, and suddenly you hit a brick wall: Authentication.
I've been there. In the past, I spent days configuring entire IdentityServer instances or wrestling with Keycloak integrations just to protect two or three endpoints of an MVP. It's frustrating: you burn 80% of the project's early hours setting up security infrastructure and only 20% delivering actual value.
Starting with a full-scale identity solution to validate an idea is classic over-engineering. But the opposite extreme — hardcoding secrets and skipping security "just for now" — is how technical debt is born with interest rates you can't afford.
So last weekend I set out to find the pragmatic middle ground: a lean, stateless, and cheap Token API, built with .NET 8 on the Isolated Worker model with Azure Functions. This is the chronicle of that MVP — what worked, what I'd do differently, and the trade-offs that actually matter.
Why Azure Functions for a Token MVP?
When validating an architecture, we need pieces that move fast and cost little. An HTTP-triggered Azure Function is the ideal home for a token-issuing API because:
- Scale to zero: If nobody is using your MVP at 3 AM, you pay nothing.
- Native decoupling: Your token generation logic lives isolated from your main API. When the MVP grows and you migrate to Auth0 or Entra ID, you swap this one service — your business API barely notices.
- .NET 8 Isolated Worker: Forget the dependency-conflict nightmares of the in-process model. The isolated model runs your Function as a standard .NET console application, giving you full control over dependency injection and middleware.
If you're wondering about the performance of this stack, I've already dissected it in .NET 8 Azure Functions: The Definitive Guide to Annihilating Cold Starts — everything there applies here, especially keeping the DI container on a diet.
Hands-On: Building Our JWT Issuer
The premise is simple: the client sends credentials, and our Function returns a signed JSON Web Token (JWT). Three pieces: the bootstrap, the endpoint, and the token service.
First, the Program.cs. This is where the Isolated Worker model shines — it's just a regular .NET host:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices(services =>
{
services.AddSingleton<ITokenService, TokenService>();
})
.Build();
host.Run();
Now the endpoint. Note the C# 12 primary constructor — dependencies are injected right in the class declaration, no boilerplate fields or constructor body. Cleaner, more modern, and the DI container doesn't care either way:
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
public sealed class TokenIssuerFunction(
ILogger<TokenIssuerFunction> logger,
ITokenService tokenService)
{
[Function("GenerateToken")]
public async Task<HttpResponseData> RunAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/login")] HttpRequestData req)
{
logger.LogInformation("Received authentication request.");
// In a real scenario, you would bind the request body and
// validate the user against your store. To keep the focus
// on token issuance, we assume the credentials are valid.
bool credentialsAreValid = true;
if (!credentialsAreValid)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var token = tokenService.CreateJwtToken("mvp_user", "admin_role");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { AccessToken = token, ExpiresIn = 3600 });
return response;
}
}
The Heart of the Service: Signing the JWT
The real magic happens in our ITokenService. This is where many tutorials go wrong by placing the secret key directly in the code. Even in an MVP, basic security is non-negotiable.
Always pull your secret key from environment variables (or Azure Key Vault, if it's already set up) — and fail loudly if it's missing:
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
public sealed class TokenService(IConfiguration config) : ITokenService
{
public string CreateJwtToken(string username, string role)
{
// Never hardcode this key! And never fail silently either.
var secretKey = config["Jwt:SecretKey"]
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
var issuer = config["Jwt:Issuer"];
var audience = config["Jwt:Audience"];
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Role, role)
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(1),
Issuer = issuer,
Audience = audience,
SigningCredentials = credentials
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
With just these classes, we've completely isolated the authentication responsibility from our main application. Our business API — whether it's in Go, Python, or another .NET service — now only needs to validate the JWT signature, without knowing anything about the user database.
- Technical Decision: Sign tokens with HS256 (symmetric key) instead of RS256 (asymmetric key pair).
- Result: The simplest possible setup — one secret, one line of configuration, tokens issued and validated in minutes. Perfect for an MVP where a single issuer and a single API are involved.
- The Trade-off (The Real Lesson): HS256 means every service that validates the token needs the same shared secret — and anyone holding that secret can also issue tokens. The moment a second or third service needs to validate your JWTs, you've outgrown HS256. Switch to RS256: the Function keeps the private key, consumers only get the public one. Because everything hides behind
ITokenService, that swap is invisible to the rest of the codebase. Design for the swap from day one, even if you don't make it on day one.
When to Graduate from This MVP
A lean Token API is a starting point, not a destination. Here's the honest comparison:
| Aspect | Lean Token API (this MVP) | Full Identity Provider (Auth0, Entra ID, Keycloak) |
|---|---|---|
| Time to first endpoint | Hours | Days |
| Monthly cost at MVP scale | ~ $0 (Consumption plan) | License / instance costs from day one |
| Refresh tokens, MFA, social login | You build it (don't) | Out of the box |
| Ideal for | Validating an idea, internal tools | Anything with real users and real risk |
The rule of thumb I landed on: the day you catch yourself implementing refresh token rotation or password reset flows by hand, stop. That's the signal your MVP graduated — migrate to a managed identity provider and let this Function retire with honor.
Key Takeaways
- Avoid day-zero over-engineering: Don't spend weeks configuring a massive identity provider to validate an idea. An Azure Function issuing JWTs is enough to start securely.
- Decoupling is king: Keeping your token issuer isolated in a Function means that when the time comes to migrate to an enterprise solution, your main API will barely feel it.
- MVP is not an excuse to leak secrets: The .NET 8 Isolated Worker gives you clean dependency injection and full control — use it. Keep your keys in environment variables or Key Vault from day one, and make missing configuration fail loudly.
Conclusion: Start Lean, Design for the Swap
This MVP took one Sunday afternoon, costs virtually nothing to run, and protects my endpoints well enough to validate the product idea it serves. But the real win isn't the code — it's the boundary. By isolating authentication behind a tiny, replaceable service, I bought myself the freedom to be simple today and sophisticated later, without rewriting anything that matters.
That's what pragmatic engineering looks like: not choosing between speed and quality, but knowing exactly which corners you're cutting — and leaving a clean edge to glue the proper solution onto later.
📣 Let's Keep the Conversation Going!
How do you handle authentication in your MVPs? Do you jump straight to an external provider, or build something lean and custom? Have you ever regretted either choice? Share your war stories in the comments!
👉 Follow me on Hashnode and connect on LinkedIn for more content on Clean Code, Cloud, and high-performance backend engineering.
🔖 If this article saved you a few hours of headache, share it with that colleague who's stuck on the architecture of their new project!





