Intro
This demo demonstrates protecting an ASP.NET Core API with OAuth2 and JWT tokens. We use Duende IdentityServer as the authorization server. The API validates tokens and allows only authenticated requests.
Why this matters: OAuth2 is the standard for securing APIs. Understanding token-based authentication helps you build production-ready applications. This setup shows the complete flow from token request to protected endpoint access.
Project / Setup Overview
The solution contains two projects:
- IdentityServer – Issues OAuth2 tokens
- ApiDemo – Protected API that validates JWT tokens
Folder Structure:
CSharp-API-OAuth2-Demo/
├── IdentityServer/
│ ├── Config.cs # Client, scope, and resource definitions
│ ├── Users.cs # Test user credentials
│ ├── Program.cs # IdentityServer setup
│ └── wwwroot/
│ └── test-token.html # Token request form
└── ApiDemo/
├── Controllers/
│ └── UsersController.cs # Protected endpoint
├── Configuration/
│ └── IdentityServerOptions.cs # Strongly-typed config
└── Program.cs # API with JWT validation
Endpoints:
-
POST /connect/token(IdentityServer) – Request access token -
GET /Users(ApiDemo) – Protected endpoint requiring a valid token
How It Works
IdentityServer Configuration
IdentityServer defines clients, scopes, and resources. Clients represent applications that request tokens. Scopes define what the token allows. Resources are the APIs being protected.
public static IEnumerable<Client> Clients =>
new List<Client>
{
new Client
{
ClientId = "demo-client",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
ClientSecrets = { new Secret("demo-secret".Sha256()) },
AllowedScopes = { "api.read" }
}
};
Note: Resource Owner Password grant is simple for demos. It allows direct username/password exchange for tokens. Production systems should use Authorization Code with PKCE for better security.
The API resource defines the audience that tokens are issued for:
new ApiResource("api-demo", "API Demo")
{
Scopes = { "api.read" }
}
API Token Validation
The API validates incoming JWT tokens using the JwtBearer authentication scheme. It checks the token signature against IdentityServer's public key and validates the audience to ensure the token was issued for this API.
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = authority; // IdentityServer URL
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidAudience = "api-demo"
};
});
Configuration validation: The Authority URL comes from appsettings.json. Validation at startup ensures required settings are present:
builder.Services.AddOptions<IdentityServerOptions>()
.Bind(builder.Configuration.GetSection("IdentityServer"))
.ValidateDataAnnotations()
.Validate(options => !string.IsNullOrWhiteSpace(options.Authority),
"IdentityServer:Authority configuration value is required.")
.ValidateOnStart();
Protected Endpoint
Controllers use the [Authorize] attribute to require authentication:
[HttpGet]
[Authorize]
public IActionResult Get() => Ok(new List<object>
{
new { Name = "Demo User", Role = "Admin" }
});
- Without a valid token: 401 Unauthorized
- With a valid token: returns user data
Demo / Usage
Step 1: Start Both Projects
Run IdentityServer on https://localhost:5001 and ApiDemo on https://localhost:5002. Both projects enforce HTTPS redirection.
Step 2: Request a Token
Navigate to https://localhost:5001/test-token.html. The form is pre-filled with demo credentials:
-
Username:
demo -
Password:
password -
Client ID:
demo-client -
Client Secret:
demo-secret -
Scope:
api.read
Click Get Token. The response contains an access_token field. Copy this token.
Step 3: Call the Protected API
Use the token in the Authorization header. Format: Bearer <your-token>
Using Swagger:
- Open
https://localhost:5002/swagger - Click Authorize
- Enter
Bearer <your-token> - Click Authorize
- Call the
GET /Usersendpoint
Using curl:
curl -H "Authorization: Bearer <your-token>" \
https://localhost:5002/Users
Expected response:
[
{
"name": "Demo User",
"role": "Admin"
}
]
Without token:
curl https://localhost:5002/Users
# Returns 401 Unauthorized
Error handling: The HTML form shows clear notifications for errors, including network failures and invalid credentials. Clipboard copy operations also handle browser restrictions.
Next Steps / Extensions
- Replace Resource Owner Password grant – Use Authorization Code with PKCE for web apps, or Client Credentials for service-to-service.
- Store users in a database – Replace test users with ASP.NET Core Identity.
-
Store clients dynamically – Use
IClientStorefor database-driven client management. - Add refresh tokens – Implement token refresh flow for longer sessions.
-
Add role-based authorization – Enforce roles using claims:
[Authorize(Roles = "Admin")]. -
Environment-specific configuration – Use
appsettings.Production.jsonand secure secrets in Azure Key Vault or similar. - Add CORS – Configure for APIs used by web apps from different origins.
Learning resources:
- Duende IdentityServer Documentation
- OAuth 2.0 Specification
- JWT.io – Decode and inspect JWT tokens
TL;DR
IdentityServer issues JWT tokens. The API validates them to protect endpoints using [Authorize]. Configuration validation ensures required settings are present. Extend this with database-backed users, different grant types, and production-ready security practices.
GitHub Repository: [https://github.com/karnafun/identityserver-oauth2-demo]
Looking for help building secure APIs? Hire me
Backend engineer & API integrator. Building secure, scalable APIs with .NET.




Top comments (0)