Advanced

Securing ASP.NET Core 10 with OAuth2 and OpenID Connect

OAuth2/OIDC with Duende IdentityServer — claims, API security, token revocation and the BFF pattern.

ASP.NET Core 10, Duende IdentityServer, OAuth 2.0, OpenID Connect


Table of Contents

  1. Application Security Architecture
  2. Authentication with OpenID Connect
  3. Securing the Authentication Process
  4. Working with Claims
  5. Authorization with OAuth2 and OpenID Connect
  6. Securing the API
  7. Authorization Policies and Access Control
  8. Token Expiration and Token Revocation
  9. Securing JavaScript Clients (BFF Pattern)
  10. Reference Tables

1. Application Security Architecture

Why a Centralized Identity Provider?

Delegating identity management to an Identity Provider (IdP) enables:

  • Centralization: user registration, password policies, password reset, MFA in one place
  • Security: modern hashing algorithms like Argon2 or bcrypt for passwords
  • Reusability: single authentication for multiple applications (Single Sign-On)

⚠️ An application should never be responsible for verifying who a user is. That’s the Identity Provider’s responsibility.

Key Vocabulary

TermDefinition
Identity Provider (IdP)Service that authenticates users and provides identity proofs
Authorization ServerServer that issues access tokens (can be same as IdP)
Security Token Service (STS)Generic term for a server issuing security tokens
Relying Party (RP)Client that relies on the IdP for authentication
Duende IdentityServerOpen-source OAuth2/OIDC implementation for .NET

2. Authentication with OpenID Connect

Core Concepts

OpenID Connect (OIDC) is a simple identity layer on top of OAuth 2. It extends and replaces OAuth 2 for scenarios involving users.

  • Defines how a client can obtain an identity token and/or access token from an IdP
  • Identity token = proof of user identity (JWT format)
  • Access token = authorization to access a resource (API)
  • Requires TLS (HTTPS) — mandatory

Confidential Clients vs Public Clients

Confidential ClientPublic Client
ExamplesASP.NET Core MVC/APIAngular, React, Blazor WASM
ExecutionControlled serverBrowser
Store secrets?YesNo
Client auth?YesNo

OpenID Connect Endpoints

EndpointUsage
Authorization EndpointAuthentication request — redirects user to IdP
Token EndpointExchange authorization code for tokens (back-channel)
UserInfo EndpointRetrieve additional claims with an access token
Discovery Document/.well-known/openid-configuration — IdP metadata
End Session EndpointLogout at the IdP level
Token Revocation EndpointToken revocation (refresh tokens)

Identity Token (JWT) Claims

ClaimMeaning
subSubject — unique user identifier at the IdP
issIssuer — IdP URI
audAudience — destination client ID
iatIssued At — emission timestamp (Unix)
expExpiration — token invalid after this date
auth_timeTime of original authentication
amrAuthentication Methods References (e.g., pwd, otp, mfa)
nonceProtection against CSRF attacks

3. Securing the Authentication Process

Authorization Code Flow

sequenceDiagram
    participant User as User
    participant Client as Client (MVC)
    participant IDP as Identity Provider
    
    User->>Client: Access to protected resource
    Client->>User: Redirect to IDP (Authorization Endpoint)
    User->>IDP: Provides credentials
    IDP->>User: Redirect to Client with Authorization Code
    Client->>IDP: POST Token Endpoint (code + client_id + client_secret)
    IDP->>Client: Identity Token + Access Token (+ Refresh Token)
    Client->>Client: Validates Identity Token, creates ClaimsIdentity
    Client->>User: Access granted (Authentication Cookie)

Authorization Code Flow + PKCE

PKCE (Proof Key for Code Exchange) protects against code injection attacks.

sequenceDiagram
    participant Client
    participant IDP as Identity Provider
    
    Client->>Client: Generate code_verifier (random string)
    Client->>Client: Hash → code_challenge = SHA256(code_verifier)
    Client->>IDP: Auth Request + code_challenge
    IDP->>Client: Returns authorization_code
    Client->>IDP: POST Token Endpoint<br/>code + code_verifier + client credentials
    IDP->>IDP: Hash(code_verifier) == code_challenge?
    IDP->>Client: Tokens (only if match ✓)

✅ PKCE is enabled by default in Microsoft’s ASP.NET Core OIDC middleware.

ASP.NET Core Client Configuration

// Program.cs — ASP.NET Core MVC Client
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.AccessDeniedPath = "/Authentication/AccessDenied";
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Authority = "https://localhost:5001/";
    options.ClientId = "imagegalleryclient";
    options.ClientSecret = "secret";
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.MapInboundClaims = false;

    options.Scope.Add("roles");
    options.Scope.Add("country");
    options.Scope.Add("offline_access");
    options.Scope.Add("imagegalleryapi.write");

    options.ClaimActions.Remove("aud");
    options.ClaimActions.DeleteClaims("sid", "idp");
    options.ClaimActions.MapJsonKey("role", "role");
    options.ClaimActions.MapUniqueJsonKey("country", "country");

    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "given_name",
        RoleClaimType = "role"
    };
});

Logout (Client + IDP)

[Authorize]
public async Task Logout()
{
    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
}

4. Working with Claims

UserInfo Endpoint

By default, the identity token only contains sub. Other claims are retrieved via the UserInfo Endpoint:

options.GetClaimsFromUserInfoEndpoint = true;

Claims Mapping

options.MapInboundClaims = false;          // Disable WS-Security mapping

options.ClaimActions.Remove("aud");         // Re-add a filtered claim
options.ClaimActions.DeleteClaims("sid", "idp");  // Remove unnecessary claims
options.ClaimActions.MapJsonKey("role", "role");
options.ClaimActions.MapUniqueJsonKey("country", "country");

Role-based Authorization (RBAC)

options.TokenValidationParameters = new TokenValidationParameters
{
    NameClaimType = "given_name",
    RoleClaimType = "role"
};
[Authorize(Roles = "PayingUser")]
public IActionResult AddImage() { ... }

5. Authorization with OAuth2 and OpenID Connect

OAuth2 Flows

FlowStatusUsage
Authorization Code + PKCERecommendedConfidential and Public clients
Client CredentialsMachine-to-machineBackground services, daemon apps
Hybrid FlowDiscouragedReplaced by Authorization Code + PKCE
Implicit FlowObsoleteDo not use
Resource Owner Password CredentialsLegacy onlyAvoid

Access Token Claims (decoded example)

{
  "sub": "b753...",
  "iss": "https://localhost:5001",
  "aud": ["imagegalleryapi"],
  "client_id": "imagegalleryclient",
  "scope": "openid imagegalleryapi.write profile country",
  "role": "PayingUser",
  "country": "be"
}

6. Securing the API

JWT Bearer Authentication

// Program.cs — API
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.Authority = "https://localhost:5001";
    options.Audience = "imagegalleryapi";
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "given_name",
        RoleClaimType = "role",
        ValidTypes = ["at+jwt"]
    };
    options.MapInboundClaims = false;
});

Passing the Access Token to the API

// Program.cs — Client
builder.Services.AddOpenIdConnectAccessTokenManagement();

builder.Services.AddHttpClient("APIClient", client =>
{
    client.BaseAddress = new Uri("https://localhost:7075");
})
.AddUserAccessTokenHandler();

Using Claims in the API Controller

[HttpGet]
[Authorize]
public async Task<IActionResult> GetImages()
{
    var ownerId = User.Claims
        .FirstOrDefault(c => c.Type == "sub")?.Value;
    
    var images = await _repository.GetImagesAsync(ownerId);
    return Ok(images);
}

🔒 Never accept OwnerId from the request body. Always read it from validated token claims.


7. Authorization Policies and Access Control

RBAC vs ABAC

RBACABAC
Predefined rolesPolicies based on attributes/claims
Simple but not extensibleVery flexible — recommended
Can lead to role explosionA few policies cover many cases

Creating a Shared Authorization Policy

public static class AuthorizationPolicies
{
    public static AuthorizationPolicy CanAddImage()
    {
        return new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .RequireClaim("country", "be")
            .RequireRole("PayingUser")
            .Build();
    }
}

Registering and Applying Policies

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("UserCanAddImage", AuthorizationPolicies.CanAddImage())
    .AddPolicy("ClientApplicationCanWrite", policyBuilder =>
    {
        policyBuilder.RequireClaim("scope", "imagegalleryapi.write");
    })
    .AddPolicy("MustOwnImage", policyBuilder =>
    {
        policyBuilder.RequireAuthenticatedUser();
        policyBuilder.AddRequirements(new MustOwnImageRequirement());
    });

[Authorize(Policy = "UserCanAddImage")]
public IActionResult AddImage() { ... }

Custom Requirements and Handlers

public class MustOwnImageRequirement : IAuthorizationRequirement { }

public class MustOwnImageHandler(
    IHttpContextAccessor httpContextAccessor,
    IGalleryRepository galleryRepository)
    : AuthorizationHandler<MustOwnImageRequirement>
{
    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MustOwnImageRequirement requirement)
    {
        var imageId = httpContextAccessor.HttpContext?
            .GetRouteValue("id")?.ToString();

        if (!Guid.TryParse(imageId, out Guid imageIdAsGuid))
        {
            context.Fail();
            return;
        }

        var ownerId = context.User.Claims
            .FirstOrDefault(c => c.Type == "sub")?.Value;

        if (ownerId == null || 
            !await galleryRepository.IsImageOwnerAsync(imageIdAsGuid, ownerId))
        {
            context.Fail();
            return;
        }

        context.Succeed(requirement);
    }
}

// Register in DI:
builder.Services.AddScoped<IAuthorizationHandler, MustOwnImageHandler>();

8. Token Expiration and Token Revocation

Token Lifetimes

TokenTypical LifetimeNote
Identity Token5 minutesUsed once to create ClaimsIdentity
Access Token~1 hourControlled by IdP
Refresh TokenDays / WeeksConfigured at IdP

Refresh Token Flow

sequenceDiagram
    participant Client
    participant IDP as Identity Provider
    
    Client->>Client: Access Token expired
    Client->>IDP: POST /token<br/>grant_type=refresh_token<br/>refresh_token=xyz
    IDP->>Client: New Access Token + New Refresh Token
    Note over Client,IDP: User sees nothing — back-channel only
// Enable refresh tokens
options.Scope.Add("offline_access");

// Auto-refresh via Duende.AccessTokenManagement
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddHttpClient("APIClient", ...)
    .AddUserAccessTokenHandler();

Token Revocation on Logout

.AddCookie(options =>
{
    options.Events.OnSigningOut = async e =>
    {
        await e.HttpContext.RevokeRefreshTokenAsync();
    };
})

9. Securing JavaScript Clients (BFF Pattern)

The Problem with SPAs and Tokens

Tokens stored client-side in JavaScript are vulnerable:

  • XSS: malicious code can read localStorage or sessionStorage
  • No secure client secret: any secret in JavaScript is accessible to users

Backend-for-Frontend (BFF) Pattern

flowchart TB
    subgraph Browser
        SPA[JavaScript SPA]
    end
    subgraph "ASP.NET Core Host (BFF)"
        OIDC[OIDC Middleware]
        PROXY[Reverse Proxy\nDuende.BFF.Yarp]
        SESSION[Session + Cookie\nHTTP-only]
    end
    subgraph Backend
        API[Remote API]
        IDP[Identity Provider]
    end

    SPA -->|Local request /bff/images| PROXY
    PROXY -->|Proxied request + Bearer Token| API
    OIDC <-->|OAuth2/OIDC Flow| IDP
    SESSION -.->|HTTP-only encrypted Cookie| SPA

BFF Key Principles:

  • OAuth2/OIDC authentication happens server-side (ASP.NET Core host)
  • Tokens are never exposed to JavaScript
  • JavaScript communicates with its host via local endpoints
  • Host proxies requests to remote APIs, adding the Bearer token

BFF Configuration with Duende.BFF

builder.Services.AddBff()
    .AddRemoteApis();

app.MapRemoteBffApiEndpoint(
    "/bff/images",
    "https://localhost:7075/api/images")
    .RequireAccessToken();

10. Reference Tables

Architectural Decision Summary

ScenarioRecommendation
Server-side web app (MVC)Authorization Code + PKCE + Cookie
REST APIJWT Bearer Authentication
SPA (Angular, React)BFF Pattern — Duende.BFF + YARP
Native mobileAuthorization Code + PKCE (no secret)
Service-to-serviceClient Credentials Flow
Refresh without interactionoffline_access scope + Duende.AccessTokenManagement
Claims in access tokenConfigure ApiResource with claims to include
Fine-grained authorizationAuthorization Policies + Custom Requirements/Handlers
Secure logoutSignOutAsync Cookie + OIDC + RevokeRefreshTokenAsync
Claims transformationMapInboundClaims=false + ClaimActions

⚠️ Never log tokens in production. Token logging in course examples is for educational purposes only.


Search Terms

asp.net · securing · core · oauth2 · openid · connect · authentication · security · c# · .net · development · token · authorization · claims · access · api · bff · flow · client · clients · configuration · identity · jwt · logout

Interested in this course?

Contact us to book it or get a custom training plan for your team.