Advanced

Securing ASP.NET Core with OAuth2 and OpenID Connect

Authorization code flow with PKCE, claims, refresh tokens, ASP.NET Core Identity, federation and MFA.

Demo application: ImageGallery (MVC client + API + Marvin.IDP with Duende IdentityServer) ASP.NET Core 8


Table of Contents

  1. Overview and Architecture
  2. Secure Architectures and History
  3. OpenID Connect — Introduction and Flows
  4. Authorization Code Flow + PKCE
  5. Claims Transformation
  6. OAuth2 — Client-Side Authorization
  7. Securing the API
  8. Authorization Policies — RBAC vs ABAC
  9. Token Expiration, Refresh Tokens, Reference Tokens
  10. BFF Pattern for JavaScript Clients
  11. User Database — Local Configuration
  12. ASP.NET Core Identity — Integration
  13. Federation — Active Directory, Entra ID, Facebook
  14. Federated Identity and User Provisioning
  15. MFA with TOTP (Authenticator Apps)
  16. Advanced ASP.NET Core Identity
  17. Deploying to Production

1. Overview and Architecture

The ImageGallery application consists of three projects:

┌─────────────────────────────────────────────────────────────────────┐
│                     ImageGallery ARCHITECTURE                       │
│                                                                     │
│   ┌──────────────────┐    ┌──────────────────┐   ┌──────────────┐  │
│   │  ImageGallery    │    │  ImageGallery    │   │  Marvin.IDP  │  │
│   │     .Client      │◄──►│      .API        │   │  (Duende     │  │
│   │  (ASP.NET MVC)   │    │  (ASP.NET Core   │   │  Identity    │  │
│   │                  │    │   Web API)        │   │   Server)    │  │
│   │  Port: 7184      │    │  Port: 7075       │   │  Port: 5001  │  │
│   └──────────────────┘    └──────────────────┘   └──────────────┘  │
│           │                        │                      │         │
│           │     Authorization Code Flow + PKCE            │         │
│           └──────────────────────────────────────────────►│         │
│           │◄─ ID Token + Access Token ───────────────────┘│         │
│           │                        │                                │
│           │───── Bearer Token ────►│                               │
│           │◄──── Data ────────────┘                               │
└─────────────────────────────────────────────────────────────────────┘

Duende IdentityServer is the open-source library used to build the identity provider (IDP) Marvin.IDP. It implements OAuth2 and OpenID Connect.


2. Secure Architectures and History

2.1 Architecture Evolution

Historical problem: traditional applications managed authentication themselves → password in each app, no SSO.

Monolithic architecture → client/API separation → need for a centralized identity provider.

BEFORE (Monolithic)           AFTER (Microservices / SPA)
─────────────────────         ──────────────────────────────
  ┌──────────────────┐          ┌─────┐  ┌─────┐  ┌─────┐
  │    Everything    │          │ IDP │  │ API │  │ API │
  │   in a single   │          └──┬──┘  └──┬──┘  └──┬──┘
  │       app        │             │        │        │
  │  (auth + data)   │          ┌──┴────────┴────────┴──┐
  └──────────────────┘          │       Client App        │
                                └────────────────────────┘

2.2 Industry Standards

StandardRole
OAuth 2.0Authorization protocol (access delegation)
OpenID Connect (OIDC)Authentication layer on top of OAuth2
JWTToken format (signed, optionally encrypted)

3. OpenID Connect — Introduction and Flows

3.1 Roles in OpenID Connect

┌────────────────────────────────────────────────────────────┐
│                   OPENID CONNECT ROLES                     │
│                                                            │
│  End User ──► Resource Owner (owns the data)               │
│                                                            │
│  Client ──── Application requesting access                 │
│                                                            │
│  IDP ──────── Identity Provider (Marvin.IDP)               │
│               Authenticates the user                       │
│               Issues tokens                                │
│                                                            │
│  Resource    Protected API (ImageGallery.API)              │
│  Server ────  Validates access tokens                      │
└────────────────────────────────────────────────────────────┘

3.2 Token Types

TokenContainsUsage
ID TokenUser identity (JWT)Who the user is
Access TokenResource access authorizationAPI calls
Refresh TokenCredential for obtaining new tokensRenewal

3.3 OpenID Connect Flows

  • Authorization Code Flow: recommended for web apps (code exchanged for token server-side)
  • Implicit Flow: deprecated (tokens in URL)
  • Client Credentials: for machine-to-machine (no user)
  • Device Flow: for devices without screens

4. Authorization Code Flow + PKCE

4.1 Complete Flow

sequenceDiagram
    participant U as User
    participant C as Client (MVC)
    participant IDP as Marvin.IDP
    participant API as ImageGallery.API

    U->>C: Accesses protected page
    C->>C: Generates code_verifier + code_challenge (PKCE)
    C->>IDP: Redirect to /authorize with code_challenge
    IDP->>U: Login page
    U->>IDP: Credentials
    IDP->>C: Authorization code (redirect)
    C->>IDP: POST /token with code + code_verifier
    IDP->>C: ID Token + Access Token (+ Refresh Token)
    C->>API: Request with Bearer Token
    API->>C: Data

PKCE (Proof Key for Code Exchange): protects against authorization code interception.

4.2 Client-Side Configuration

// Program.cs — ImageGallery.Client
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Authority = "https://localhost:5001";
    options.ClientId = "imagegalleryclient";
    options.ClientSecret = "secret";
    options.ResponseType = "code";
    
    // Requested scopes
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add("imagegalleryapi.fullaccess");
    
    // Save tokens in the session cookie
    options.SaveTokens = true;
    
    // PKCE enabled by default in .NET
    options.UsePkce = true;
    
    options.CallbackPath = "/signin-oidc";
});

4.3 IDP Configuration (Duende IdentityServer)

// Config.cs — Marvin.IDP
public static IEnumerable<Client> Clients =>
    new Client[]
    {
        new Client
        {
            ClientId = "imagegalleryclient",
            ClientName = "Image Gallery",
            AllowedGrantTypes = GrantTypes.Code,
            RequirePkce = true,
            ClientSecrets = { new Secret("secret".Sha256()) },
            RedirectUris = { "https://localhost:7184/signin-oidc" },
            PostLogoutRedirectUris = { "https://localhost:7184/signout-callback-oidc" },
            AllowedScopes =
            {
                IdentityServerConstants.StandardScopes.OpenId,
                IdentityServerConstants.StandardScopes.Profile,
                IdentityServerConstants.StandardScopes.Email,
                "imagegalleryapi.fullaccess"
            },
            AllowOfflineAccess = true // Refresh tokens
        }
    };

5. Claims Transformation

5.1 Problem

JWT token claims don’t always match those expected by ASP.NET Core. Mapping is required.

5.2 Clearing Default Mapping

// Disable automatic mapping (transforms "sub" into "nameidentifier")
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

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

5.3 Custom Claims Transformation

public class ClaimsTransformation : IClaimsTransformation
{
    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var claimsIdentity = (ClaimsIdentity)principal.Identity;
        
        // Add a claim based on another
        if (claimsIdentity.HasClaim(c => c.Type == "country" && c.Value == "be"))
        {
            claimsIdentity.AddClaim(new Claim("subscriptionlevel", "FreeUser"));
        }
        
        return Task.FromResult(principal);
    }
}

// Registration
builder.Services.AddTransient<IClaimsTransformation, ClaimsTransformation>();

5.4 IProfileService on IDP Side

To include custom claims in the token:

public class LocalUserProfileService : IProfileService
{
    private readonly ILocalUserService _localUserService;
    
    public LocalUserProfileService(ILocalUserService localUserService)
    {
        _localUserService = localUserService;
    }
    
    public async Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        var subjectId = context.Subject.GetSubjectId();
        var claimsForUser = await _localUserService.GetUserClaimsBySubjectAsync(subjectId);
        
        context.AddRequestedClaims(
            claimsForUser.Select(c => new Claim(c.Type, c.Value)).ToList()
        );
    }
    
    public async Task IsActiveAsync(IsActiveContext context)
    {
        var subjectId = context.Subject.GetSubjectId();
        context.IsActive = await _localUserService.IsUserActiveAsync(subjectId);
    }
}

6. OAuth2 — Client-Side Authorization

6.1 Scopes and Authorization

Scopes requested by client → included in the access token
→ API validates scopes → grants or denies access

Scope types:

  • Identity scopes: openid, profile, email, address, phone
  • Resource scopes: imagegalleryapi.fullaccess, imagegalleryapi.read

7. Securing the API

7.1 JWT Bearer Token Validation

// Program.cs — ImageGallery.API
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:5001";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false,
            NameClaimType = "given_name",
            RoleClaimType = "role"
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("UserCanAddImage", AuthorizationPolicies.CanAddImage());
    options.AddPolicy("ClientApplicationCanWrite", policy =>
        policy.RequireClaim("scope", "imagegalleryapi.write"));
});

// In the pipeline
app.UseAuthentication();
app.UseAuthorization();

7.2 Accessing Claims in the API

[ApiController]
[Authorize]
[Route("api/images")]
public class ImagesController : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<IEnumerable<Image>>> GetImages()
    {
        // Get the sub (user ID) from the token
        var ownerId = User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
        
        var images = await _imageService.GetImages(ownerId);
        return Ok(images);
    }
}

8. Authorization Policies — RBAC vs ABAC

8.1 RBAC (Role-Based Access Control)

// Role-based authorization
[Authorize(Roles = "PayingUser")]
public IActionResult PremiumContent()
{
    return View();
}

8.2 ABAC (Attribute-Based Access Control) via Policies

// Policy definition
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanAddImage", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("subscriptionlevel", "PayingUser")
              .RequireClaim("country", "be"));
});

// Usage
[Authorize(Policy = "CanAddImage")]
public IActionResult AddImage() { ... }

8.3 Custom Requirements and Handlers

// Requirement
public class MustOwnImageRequirement : IAuthorizationRequirement { }

// Handler
public class MustOwnImageHandler : AuthorizationHandler<MustOwnImageRequirement>
{
    private readonly IImageRepository _imageRepository;
    private readonly IHttpContextAccessor _httpContextAccessor;
    
    public MustOwnImageHandler(
        IImageRepository imageRepository,
        IHttpContextAccessor httpContextAccessor)
    {
        _imageRepository = imageRepository;
        _httpContextAccessor = httpContextAccessor;
    }
    
    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MustOwnImageRequirement requirement)
    {
        var imageId = Guid.Parse(
            _httpContextAccessor.HttpContext.GetRouteValue("imageId")?.ToString());
        
        var ownerId = context.User.Claims
            .FirstOrDefault(c => c.Type == "sub")?.Value;
        
        if (await _imageRepository.IsImageOwnerAsync(imageId, ownerId))
        {
            context.Succeed(requirement);
        }
    }
}

// Registration
builder.Services.AddSingleton<IAuthorizationHandler, MustOwnImageHandler>();

9. Token Expiration, Refresh Tokens, Reference Tokens

9.1 Refresh Tokens

sequenceDiagram
    participant C as Client
    participant IDP as IDP

    Note over C: Access Token expired
    C->>IDP: POST /token (grant_type=refresh_token, refresh_token=xxx)
    IDP->>C: New Access Token + new Refresh Token
    Note over C: Refresh Token rotation

IdentityModel.AspNetCore configuration for automatic management:

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

// Registration with automatic refresh
builder.Services.AddUserAccessTokenHttpClient("APIClient", configureClient: client =>
{
    client.BaseAddress = new Uri("https://localhost:7075/api/");
});

Required scope on client side:

options.Scope.Add("offline_access"); // For refresh tokens

9.2 Reference Tokens (Introspection)

Instead of a self-validating JWT, the token is an opaque reference stored in the IDP.

// IDP Config — enable reference tokens for a client
new Client
{
    AccessTokenType = AccessTokenType.Reference,
    // ...
}

// API — validation via introspection
builder.Services.AddAuthentication()
    .AddOAuth2Introspection("introspection", options =>
    {
        options.Authority = "https://localhost:5001";
        options.ClientId = "imagegalleryapi";
        options.ClientSecret = "apisecret";
    });

9.3 Token Revocation

// Revoke a refresh token (e.g., on logout)
var client = new HttpClient();
await client.RevokeTokenAsync(new TokenRevocationRequest
{
    Address = "https://localhost:5001/connect/revocation",
    ClientId = "imagegalleryclient",
    ClientSecret = "secret",
    Token = refreshToken,
    TokenTypeHint = OidcConstants.TokenTypes.RefreshToken
});

10. BFF Pattern for JavaScript Clients

10.1 Problem with SPAs and Tokens

PROBLEM:
- Tokens in the browser (localStorage/sessionStorage) are vulnerable to XSS attacks
- SameSite=Strict cookies don't work for cross-origin APIs

SOLUTION — BFF (Backend for Frontend):
- The backend manages tokens
- The frontend communicates with the BFF via secure cookies
- The BFF calls the API with the Bearer token

10.2 BFF Architecture with Duende.BFF.Yarp

// Program.cs — BFF
builder.Services.AddBff()
    .AddRemoteApis();

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
    // OIDC Configuration...
});

// Proxy to the API
app.MapRemoteBffApiEndpoint("/api", "https://localhost:7075/api")
    .RequireAccessToken();

10.3 Duende BFF — Middleware

app.UseRouting();
app.UseAuthentication();
app.UseBff();
app.UseAuthorization();

app.MapBffManagementEndpoints(); // /bff/login, /bff/logout, /bff/user

11. User Database — Local Configuration

11.1 Custom Configuration (without ASP.NET Core Identity)

// LocalUserService — user management
public class LocalUserService : ILocalUserService
{
    private readonly IdentityDbContext _dbContext;
    
    public async Task<LocalUser?> FindUserBySubjectAsync(string subject)
    {
        return await _dbContext.Users
            .Include(u => u.Claims)
            .FirstOrDefaultAsync(u => u.Subject == subject);
    }
    
    public async Task<bool> ValidateCredentialsAsync(string userName, string password)
    {
        var user = await _dbContext.Users
            .FirstOrDefaultAsync(u => u.UserName == userName);
        
        if (user == null || !user.Active) return false;
        
        return PasswordHasher.VerifyHashedPassword(user.HashedPassword, password);
    }
}

11.2 Password Hashing

// Use ASP.NET Core Identity password hasher even without full Identity
public class PasswordHasher
{
    private readonly IPasswordHasher<LocalUser> _hasher;
    
    public PasswordHasher(IPasswordHasher<LocalUser> hasher)
    {
        _hasher = hasher;
    }
    
    public string Hash(LocalUser user, string password)
        => _hasher.HashPassword(user, password);
    
    public bool Verify(LocalUser user, string hashedPwd, string plainPwd)
        => _hasher.VerifyHashedPassword(user, hashedPwd, plainPwd) 
           != PasswordVerificationResult.Failed;
}

12. ASP.NET Core Identity — Integration

12.1 Replacing Custom UserStore with Identity

// Program.cs — IDP with ASP.NET Core Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

// Integration with IdentityServer
builder.Services.AddIdentityServer()
    .AddAspNetIdentity<ApplicationUser>()
    .AddProfileService<LocalUserProfileService>();

12.2 Extended User Model

public class ApplicationUser : IdentityUser
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? Country { get; set; }
    public string? SubscriptionLevel { get; set; }
}

13. Federation — Active Directory, Entra ID, Facebook

13.1 Adding an External Provider in IdentityServer

// Program.cs — IDP
builder.Services.AddAuthentication()
    .AddOpenIdConnect("AAD", "Azure Active Directory / Entra ID", options =>
    {
        options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
        options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0";
        options.ClientId = "azureClientId";
        options.ClientSecret = "azureClientSecret";
        options.ResponseType = "code";
        options.CallbackPath = "/signin-aad";
        options.Scope.Add("openid");
        options.Scope.Add("profile");
    })
    .AddFacebook("Facebook", options =>
    {
        options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
        options.AppId = "facebookAppId";
        options.AppSecret = "facebookAppSecret";
    });

13.2 External Callback Handling

// ExternalController.cs — Callback after external auth
[HttpGet]
public async Task<IActionResult> Callback()
{
    var result = await HttpContext.AuthenticateAsync(
        IdentityServerConstants.ExternalCookieAuthenticationScheme);
    
    var externalUser = result.Principal;
    var userIdClaim = externalUser.FindFirst(JwtRegisteredClaimNames.Sub)
                   ?? externalUser.FindFirst(ClaimTypes.NameIdentifier);
    
    var provider = result.Properties.Items["scheme"];
    var providerUserId = userIdClaim.Value;
    
    var user = await FindOrCreateUserAsync(provider, providerUserId, externalUser.Claims);
    
    await HttpContext.SignInAsync(user.SubjectId, user.Username, /* ... */);
    
    return Redirect(returnUrl);
}

14. Federated Identity and User Provisioning

14.1 Automatic Provisioning

// Create the local user on first login via an external provider
private async Task<UserAutoProvisioningResult> AutoProvisionUserAsync(
    string provider, string providerUserId, IEnumerable<Claim> claims)
{
    var sub = Guid.NewGuid().ToString();
    
    var user = new LocalUser
    {
        Subject = sub,
        UserName = $"{provider}_{providerUserId}",
        Active = true
    };
    
    var filteredClaims = claims
        .Where(c => c.Type == "name" || c.Type == "email")
        .ToList();
    
    filteredClaims.Add(new Claim("provider", provider));
    
    await _localUserService.AddUserAsync(user, providerUserId, provider, filteredClaims);
    
    return new UserAutoProvisioningResult { User = user, Claims = filteredClaims };
}

15. MFA with TOTP (Authenticator Apps)

15.1 TOTP Configuration

// MFA setup page
public async Task<IActionResult> EnableAuthenticator()
{
    var user = await _userManager.GetUserAsync(User);
    var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
    
    if (string.IsNullOrEmpty(unformattedKey))
    {
        await _userManager.ResetAuthenticatorKeyAsync(user);
        unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
    }
    
    // Generate the URI for the QR Code
    var authenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey);
    
    return View(new EnableAuthenticatorViewModel
    {
        AuthenticatorKey = FormatKey(unformattedKey),
        QrCodeUri = authenticatorUri
    });
}

15.2 TOTP Code Verification

public async Task<IActionResult> VerifyAuthenticatorCode(string code)
{
    var user = await _userManager.GetUserAsync(User);
    
    var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
        user, _userManager.Options.Tokens.AuthenticatorTokenProvider, code);
    
    if (!is2faTokenValid)
    {
        ModelState.AddModelError("code", "Invalid code.");
        return View();
    }
    
    await _userManager.SetTwoFactorEnabledAsync(user, true);
    return RedirectToAction("2FAEnabled");
}

16. Advanced ASP.NET Core Identity

16.1 Custom Stores

public class CustomUserStore : IUserStore<ApplicationUser>,
                               IUserPasswordStore<ApplicationUser>,
                               IUserClaimStore<ApplicationUser>
{
    public Task<IdentityResult> CreateAsync(ApplicationUser user, CancellationToken ct)
    {
        // Save user to the data source
    }
}

16.2 Registration and Email Confirmation

[HttpPost]
public async Task<IActionResult> Register(RegisterViewModel model)
{
    var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
    var result = await _userManager.CreateAsync(user, model.Password);
    
    if (result.Succeeded)
    {
        var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
        var confirmationLink = Url.Action("ConfirmEmail", "Account", 
            new { userId = user.Id, token }, Request.Scheme);
        
        await _emailSender.SendEmailAsync(model.Email, "Confirmation", 
            $"Click here: {confirmationLink}");
        
        return RedirectToAction("RegisterConfirmation");
    }
    
    return View(model);
}

17. Deploying to Production

17.1 Azure SQL Database

builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});

17.2 Data Protection with Azure Key Vault

builder.Services.AddDataProtection()
    .PersistKeysToAzureBlobStorage(/* connection string */)
    .ProtectKeysWithAzureKeyVault(/* Key Vault URI */);

17.3 Proxy Headers (Load Balancer)

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownNetworks.Clear();
    options.KnownProxies.Clear();
});

app.UseForwardedHeaders();

17.4 Duende IdentityServer License

⚠️ Important: Duende IdentityServer is free for personal/open-source use. For companies (> $1M/year revenue), a commercial license is required (~$1500/year). Reduced licenses exist for startups.

17.5 Production Security Checklist

ItemAction
TLS/HTTPSRequired everywhere
Token lifetimeShort access token (5-15 min), longer refresh token
Reference tokensFor immediate revocation capability
Data ProtectionPersist keys (Azure Blob + Key Vault)
Forwarded HeadersConfigure for reverse proxy
CORSConfigure strictly
SecretsNever hardcoded, use Key Vault
LogsMonitor failed attempts

Complete Config.cs — Production Configuration

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
        new IdentityResource[]
        {
            new IdentityResources.OpenId(),
            new IdentityResources.Profile(),
            new IdentityResources.Email(),
            new IdentityResource("roles", "Your role(s)", new List<string> { "role" }),
            new IdentityResource("country", "Your country", new List<string> { "country" })
        };

    public static IEnumerable<ApiResource> ApiResources =>
        new ApiResource[]
        {
            new ApiResource("imagegalleryapi", "Image Gallery API")
            {
                Scopes = { "imagegalleryapi.fullaccess", "imagegalleryapi.read", "imagegalleryapi.write" },
                UserClaims = { "role", "country" },
                ApiSecrets = { new Secret("apisecret".Sha256()) }
            }
        };

    public static IEnumerable<ApiScope> ApiScopes =>
        new ApiScope[]
        {
            new ApiScope("imagegalleryapi.fullaccess"),
            new ApiScope("imagegalleryapi.read"),
            new ApiScope("imagegalleryapi.write")
        };

    public static IEnumerable<Client> Clients =>
        new Client[]
        {
            new Client
            {
                ClientId = "imagegalleryclient",
                ClientName = "Image Gallery",
                AllowedGrantTypes = GrantTypes.Code,
                RequirePkce = true,
                ClientSecrets = { new Secret("secret".Sha256()) },
                RedirectUris = { "https://localhost:7184/signin-oidc" },
                PostLogoutRedirectUris = { "https://localhost:7184/signout-callback-oidc" },
                AllowOfflineAccess = true,
                AccessTokenType = AccessTokenType.Jwt,
                AllowedScopes =
                {
                    IdentityServerConstants.StandardScopes.OpenId,
                    IdentityServerConstants.StandardScopes.Profile,
                    "roles", "country", "imagegalleryapi.write", "imagegalleryapi.read"
                },
                AccessTokenLifetime = 3600,
                IdentityTokenLifetime = 300,
                AbsoluteRefreshTokenLifetime = 2592000,
                UpdateAccessTokenClaimsOnRefresh = true
            }
        };
}

Security Best Practices

Common Anti-Patterns

// ❌ ANTI-PATTERN 1: Token in localStorage
localStorage.setItem('access_token', token); // ❌ XSS vulnerable!

// ✅ GOOD: HttpOnly Cookie server-side (BFF pattern)

// ❌ ANTI-PATTERN 2: ownerId from request body
public async Task<IActionResult> CreateImage(ImageForCreation model)
{
    var image = new Image
    {
        OwnerId = model.OwnerId  // ❌ Attacker can modify this value!
    };
}

// ✅ GOOD: ownerId from validated access token
public async Task<IActionResult> CreateImage(ImageForCreation model)
{
    var ownerId = User.Claims.First(c => c.Type == "sub").Value; // ✅ From signed token
    var image = new Image { OwnerId = ownerId };
}

// ❌ ANTI-PATTERN 3: Implicit Flow (obsolete)
options.ResponseType = "token id_token"; // ❌ No longer use!

// ✅ GOOD: Authorization Code Flow + PKCE
options.ResponseType = "code";
options.UsePkce = true;

JWT vs Reference Tokens

CriterionJWT (Self-contained)Reference Token
FormatSigned Base64 (3 parts)Opaque identifier
ValidationLocal (signature + claims)Introspection endpoint
RevocationImpossible before expirationImmediate
API PerformanceHigh (no IDP call)Lower (IDP call per request)
SizeCan be largeSmall
Recommended usageNon-sensitive dataVery sensitive data

RBAC vs ABAC

CriterionRBACABAC
GranularityLow (predefined roles)High (claim combination)
FlexibilityLimitedVery flexible
ComplexitySimpleMore complex
Example[Authorize(Roles = "Admin")]Policy = "UserCanAddImage"
RecommendedNot for enterprisesYes — ASP.NET Core built-in

Glossary

TermDefinition
ABACAttribute-Based Access Control — authorization based on attributes (claims)
Access TokenAuthorization token for API access (JWT or Reference format)
Authorization CodeTemporary code exchanged for tokens via back channel
BFFBackend for Frontend — proxy pattern to secure SPAs
ClaimsInformation about the user (sub, email, role, country…)
PKCEProof Key for Code Exchange — protection against code injection attacks
Refresh TokenLong-lived token to renew access token without re-asking credentials
Reference TokenOpaque token validated via introspection (immediately revocable)
Sub (Subject)Unique and immutable user identifier in the IDP

Search Terms

asp.net · securing · core · oauth2 · openid · connect · authentication · security · c# · .net · development · configuration · custom · identity · tokens · authorization · token · abac · architecture · bff · claims · duende · identityserver · production

Interested in this course?

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