Demo application: ImageGallery (MVC client + API + Marvin.IDP with Duende IdentityServer) ASP.NET Core 8
Table of Contents
- Overview and Architecture
- Secure Architectures and History
- OpenID Connect — Introduction and Flows
- Authorization Code Flow + PKCE
- Claims Transformation
- OAuth2 — Client-Side Authorization
- Securing the API
- Authorization Policies — RBAC vs ABAC
- Token Expiration, Refresh Tokens, Reference Tokens
- BFF Pattern for JavaScript Clients
- User Database — Local Configuration
- ASP.NET Core Identity — Integration
- Federation — Active Directory, Entra ID, Facebook
- Federated Identity and User Provisioning
- MFA with TOTP (Authenticator Apps)
- Advanced ASP.NET Core Identity
- 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
| Standard | Role |
|---|---|
| OAuth 2.0 | Authorization protocol (access delegation) |
| OpenID Connect (OIDC) | Authentication layer on top of OAuth2 |
| JWT | Token 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
| Token | Contains | Usage |
|---|---|---|
| ID Token | User identity (JWT) | Who the user is |
| Access Token | Resource access authorization | API calls |
| Refresh Token | Credential for obtaining new tokens | Renewal |
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
| Item | Action |
|---|---|
| TLS/HTTPS | Required everywhere |
| Token lifetime | Short access token (5-15 min), longer refresh token |
| Reference tokens | For immediate revocation capability |
| Data Protection | Persist keys (Azure Blob + Key Vault) |
| Forwarded Headers | Configure for reverse proxy |
| CORS | Configure strictly |
| Secrets | Never hardcoded, use Key Vault |
| Logs | Monitor 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
| Criterion | JWT (Self-contained) | Reference Token |
|---|---|---|
| Format | Signed Base64 (3 parts) | Opaque identifier |
| Validation | Local (signature + claims) | Introspection endpoint |
| Revocation | Impossible before expiration | Immediate |
| API Performance | High (no IDP call) | Lower (IDP call per request) |
| Size | Can be large | Small |
| Recommended usage | Non-sensitive data | Very sensitive data |
RBAC vs ABAC
| Criterion | RBAC | ABAC |
|---|---|---|
| Granularity | Low (predefined roles) | High (claim combination) |
| Flexibility | Limited | Very flexible |
| Complexity | Simple | More complex |
| Example | [Authorize(Roles = "Admin")] | Policy = "UserCanAddImage" |
| Recommended | Not for enterprises | Yes — ASP.NET Core built-in |
Glossary
| Term | Definition |
|---|---|
| ABAC | Attribute-Based Access Control — authorization based on attributes (claims) |
| Access Token | Authorization token for API access (JWT or Reference format) |
| Authorization Code | Temporary code exchanged for tokens via back channel |
| BFF | Backend for Frontend — proxy pattern to secure SPAs |
| Claims | Information about the user (sub, email, role, country…) |
| PKCE | Proof Key for Code Exchange — protection against code injection attacks |
| Refresh Token | Long-lived token to renew access token without re-asking credentials |
| Reference Token | Opaque 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