Security
ArturRios.Util.WebApi ships token authentication and role-based authorization as a small, composable set of pieces: AuthenticationMiddleware extracts and…ArturRios.Util.WebApi ships token authentication and role-based authorization as a small, composable
set of pieces: AuthenticationMiddleware extracts and validates the token and attaches the user, a pair
of attributes/filters declare access rules, and Credentials/Authentication give you the record types
for a login flow.
Two token schemes are supported side by side — the app’s own HMAC JWT and Google ID tokens — and either
can be accepted on a given request. Which scheme(s) are enabled, where the token is read from, and how the
user is resolved are all controlled by one AuthenticationOptions instance, registered via
AddTokenAuthentication.
Registering authentication — AddTokenAuthentication
builder.Services.AddTokenAuthentication(options =>
{
options.Source = TokenSource.Either; // Header | Cookie | Either — default: Header
options.CookieName = "access_token"; // default
options.EnableJwt = true; // default
options.EnableGoogle = true; // default: false
options.GoogleClientIds = ["your-google-oauth-client-id"];
options.JwtMode = JwtValidationMode.ClaimsOnly; // or Revalidate — default: ClaimsOnly
});
AddTokenAuthentication registers the AuthenticationOptions instance plus one ITokenValidator per
enabled scheme (app JWT first, then Google, so that’s the order AuthenticationMiddleware tries them
in). Calling it more than once doesn’t register a validator twice. It throws an ArgumentException if:
- neither
EnableJwtnorEnableGoogleistrue— at least one scheme must be enabled; or EnableGoogleistruebutGoogleClientIdsis empty.
The app must still separately register JwtConfiguration/JwtHandler (for the JWT scheme) and, when
required (see below), an IAuthenticationProvider.
Calling AddTokenAuthentication is also what puts AuthenticationMiddleware on the pipeline:
UseStandardMiddlewares() — which WebApiStartup runs for you — adds it after TraceActivityMiddleware,
ExceptionMiddleware, Swagger and CORS whenever AuthenticationOptions is registered, and leaves it out otherwise. With
WebApiStartup, call AddTokenAuthentication from your ConfigureServices override (see
Configuration).
TokenSource — where the token is read from
AuthenticationOptions.Source controls how AuthenticationMiddleware extracts the raw token from the
request, via the HttpContext.ExtractToken(source, cookieName) extension
(ArturRios.Util.WebApi.Security.Extensions, also usable from your own code). It replaces
TokenExtractor.Extract(context, source, cookieName), which was removed in 5.0.0:
Header(default) — theAuthorizationheader only. The scheme must beBearer(case-insensitive) with a non-empty parameter; any other or malformed scheme is treated as no token.Cookie— the cookie namedCookieName(default"access_token") only.Either— the header first, falling back to the cookie if the header carries no token.
AuthenticationMiddleware
AuthenticationMiddleware runs once per request (see Architecture for where it sits in
the pipeline). For each request it:
- Skips validation entirely for Swagger routes (
/swaggerand anything under it, whenever Swagger is served — see Configuration) and for endpoints marked[AllowAnonymous](this library’s attribute or ASP.NET Core’s own, on the action or its controller). - Extracts the token per
AuthenticationOptions.Source, as above. - Runs the token through the enabled validators, in registration order (app JWT, then Google). The first
validator that resolves an
IAuthenticatedUserwins: it’s attached toHttpContext.Items["User"]and the next middleware runs. - If no validator resolves a user, the request gets a 401 with a failed
ProcessOutputenvelope carrying the last validator’s error (e.g."Invalid token","Invalid Google token","User not found") — or"Authentication token not provided"when no token was found on the request at all. The validators still run in that case; only the error text differs. The envelope is serialized with ASP.NET Core’s JSON options (camelCase), the same shape MVC uses for controller results.
The app JWT scheme — ClaimsOnly vs Revalidate
When EnableJwt is true, JwtTokenValidator first checks the token’s signature via
JwtHandler.IsTokenValidAsync; an invalid or missing token fails with "Invalid token". How the user is
then resolved is controlled by AuthenticationOptions.JwtMode:
ClaimsOnly(default) — the registered mapper’sFromClaimsrebuilds the user from the token’s claims. No data store is queried, so authentication costs nothing beyond the signature check. The trade-off: because nothing is re-checked server-side, role changes and revocations only take effect once the token expires. Keep access-token lifetimes short and pair this mode with refresh tokens.Revalidate— the user id is read from the token by the mapper’sIdFromClaims, andIAuthenticationProvideris resolved per-request fromHttpContext.RequestServices(so it can be a scoped service) and itsGetAuthenticatedUserByIdis called. This guarantees freshness — a deleted or role-changed user is rejected or updated on the very next request — at the cost of one lookup per request. AnIAuthenticationProvidermust be registered for this mode.
Rotating the signing key
JwtTokenValidator checks the token against every key in JwtConfiguration.Keys when the
configuration carries any, and against JwtConfiguration.Secret when it does not. That is what lets a
signing key be replaced without invalidating the tokens already issued: the retired key stays in
Keys until no token it signed can still be alive, and only then is it withdrawn.
services.AddSingleton(new JwtConfiguration(3600, "my-api", "my-app", string.Empty, [])
{
Keys = [new JwtKey("2026-08", previousSecret), new JwtKey("2026-09", currentSecret)],
SigningKeyId = "2026-09"
});
Nothing else changes: AuthenticationOptions is untouched, and a configuration with no Keys behaves
exactly as it did before. See the ArturRios.Jwt docs for
the rotation procedure and for how the kid header selects a key.
Google authentication
When EnableGoogle is true, GoogleTokenValidator verifies the token via IGoogleTokenVerifier — by
default GoogleTokenVerifier, backed by Google.Apis.Auth’s GoogleJsonWebSignature, which checks
signature, issuer, expiry and audience against GoogleClientIds. On success, the token’s verified email
is looked up through IAuthenticationProvider.GetAuthenticatedUserByEmail. An IAuthenticationProvider
is therefore required whenever EnableGoogle is true — the same requirement as JWT Revalidate
mode, above.
To accept Google sign-in:
- Add the
Google.Apis.Authpackage (already a dependency of this library, so it resolves transitively — add it explicitly to your project only if you call its APIs directly). - Set
EnableGoogle = trueandGoogleClientIdsto your app’s accepted OAuth client ID(s)/audiences in theAddTokenAuthenticationcallback. - Implement
IAuthenticationProvider.GetAuthenticatedUserByEmail(string)on your provider (alongsideGetAuthenticatedUserByIdif you also use the JWT scheme) and register it, optionally wrapped withAddCachedAuthenticationProvider<T>(below).
EnableJwt and EnableGoogle are independent — enable both to let a single endpoint accept either an
app-issued JWT or a Google ID token on the same request path; AuthenticationMiddleware figures out which
one it got by trying each enabled validator in turn.
Caching provider lookups
When using Revalidate, wrap your IAuthenticationProvider with CachedAuthenticationProvider so
repeated lookups of the same user within a short window are served from an IMemoryCache instead of
hitting the store on every request:
builder.Services.AddCachedAuthenticationProvider<MyAuthenticationProvider>(options =>
{
options.Ttl = TimeSpan.FromSeconds(30); // default: 60s
options.CacheMisses = true; // also cache "user not found" (default: false)
});
AddCachedAuthenticationProvider<TProvider> registers TProvider (your concrete IAuthenticationProvider
implementation) as a scoped service, adds IMemoryCache, and registers IAuthenticationProvider itself
as a CachedAuthenticationProvider decorating TProvider. Both provider methods are cached independently:
GetAuthenticatedUserById checks the cache first (key "auth:user:" + id by default, via
CacheKeyPrefix), and GetAuthenticatedUserByEmail does the same keyed by email ("auth:email:" + email
by default, via EmailCacheKeyPrefix). On a miss, each delegates to the inner provider and caches the
result for Ttl (which must be positive — AddCachedAuthenticationProvider throws an
ArgumentOutOfRangeException otherwise), but only caches a null result (a miss) when CacheMisses is
true. This bounds
staleness to the TTL while collapsing bursts of requests for the same user into a single store hit —
AuthenticationMiddleware (for both the JWT Revalidate and Google schemes) and your own code both see
it as a plain IAuthenticationProvider and don’t need to know caching is happening.
Identity types
The library knows exactly two things about a user — an id and a role id — and leaves everything else, including the token’s claim keys, to the app.
IAuthenticatedUser—Guid Idandint RoleId. Implement it on your own type, adding whatever else your app needs; instances are attached to the current request after successful authentication and returned by bothIAuthenticationProvidermethods.AuthenticatedUser(Guid Id, int RoleId)— the default implementation, for apps that need nothing extra.IAuthenticatedUserMapper— translates between your user and your claims, in both directions:ToClaims(IAuthenticatedUser)builds the claims you embed at login,FromClaims(...)rebuilds the user duringClaimsOnlyvalidation, andIdFromClaims(...)reads just the id forRevalidatemode (a default implementation delegates toFromClaims; override it when your token carries more thanFromClaimsneeds). Implementations must returnnullfor claims they cannot interpret rather than throwing — useTryParse, notParse.AddTokenAuthenticationresolves your mapper as a singleton, so it must stay stateless and free of scoped dependencies (aDbContext, a request-scoped tenant accessor).DefaultAuthenticatedUserMapper— used when you register no mapper of your own. MapsId/RoleIdthroughTokenClaimKeysand produces anAuthenticatedUser.TokenClaimKeys— the claim keys the default mapper uses:Id = "id",RoleId = "role". A custom mapper may use any keys it likes.TokenClaimsReader.Read(string token)— reads a token’s claims into a dictionary without validating its signature (callers must have already done that), ornullif the token can’t be read. A repeated claim key keeps its first occurrence.HttpContext.GetUser<TUser>()— the authenticated user as your own type, ornullif the request isn’t authenticated or the attached user is another type.GetUser()returnsIAuthenticatedUser?.
public record MyUser(Guid Id, int RoleId, string TenantId) : IAuthenticatedUser;
public class MyUserMapper : IAuthenticatedUserMapper
{
private const string TenantClaim = "tenant";
public Dictionary<string, string> ToClaims(IAuthenticatedUser user) =>
new()
{
{ TokenClaimKeys.Id, user.Id.ToString() },
{ TokenClaimKeys.RoleId, user.RoleId.ToString() },
{ TenantClaim, ((MyUser)user).TenantId }
};
public IAuthenticatedUser? FromClaims(IReadOnlyDictionary<string, string> claims)
{
if (!claims.TryGetValue(TokenClaimKeys.Id, out var id) || !Guid.TryParse(id, out var userId) ||
!claims.TryGetValue(TokenClaimKeys.RoleId, out var role) || !int.TryParse(role, out var roleId))
{
return null;
}
claims.TryGetValue(TenantClaim, out var tenantId);
return new MyUser(userId, roleId, tenantId ?? string.Empty);
}
}
Register it with the generic overload; omit the type argument to get DefaultAuthenticatedUserMapper:
builder.Services.AddTokenAuthentication<MyUserMapper>(options => { /* ... */ });
flowchart LR
User["your IAuthenticatedUser"] -- "mapper.ToClaims()" --> Claims["your claims"]
Claims -- "embedded in JWT at login" --> Token["Bearer token"]
Token -- "TokenClaimsReader.Read()" --> Claims2["claims dictionary"]
Claims2 -- "mapper.FromClaims()" --> User2["your IAuthenticatedUser"]Because ToClaims takes the interface, a mapper writing extra claims casts to its own type. The cast is
safe: the same app owns the user type, the mapper, and the provider that produced the user.
Declaring access rules
[Authorize]
[RoleRequirement(1, 2)] // e.g. Admin, Manager
public class AccountsController : ControllerBase
{
[AllowAnonymous]
[HttpPost("login")]
public IActionResult Login(Credentials credentials) { /* ... */ }
[HttpGet]
public IActionResult GetAll() { /* only roles 1 and 2 reach here */ }
}
[Authorize]— anIAuthorizationFilterthat checksHttpContext.Items["User"]; if it’snull, the request short-circuits with a 401 carrying a failedProcessOutputenvelope ({"success": false, "errors": ["Unauthorized"], ...}). It first checks the endpoint for[AllowAnonymous]and returns immediately (no 401) if present.[RoleRequirement(params int[] authorizedRoles)]— aTypeFilterAttributearoundRoleRequirementFilter. It reads the sameHttpContext.Items["User"]; if no user is present it short-circuits with a 401 (the same"Unauthorized"envelope as[Authorize]); if the user’sRoleIdis one ofauthorizedRoles, the request proceeds, otherwise it short-circuits with a 403 (aProcessOutputwith the error"You do not have permission to access this resource"). It also honors[AllowAnonymous]— an anonymous-marked action returns immediately without a role check, even under[RoleRequirement(...)].[AllowAnonymous]— a marker attribute for an action or a whole controller; it doesn’t enforce anything itself, butAuthenticationMiddleware,AuthorizeAttributeandRoleRequirementFilterall check for it and skip their own enforcement when it’s present. It implements ASP.NET Core’sIAllowAnonymous, so ASP.NET Core’s own[AllowAnonymous]is honored the same way, and this library’s attribute is in turn recognized by ASP.NET Core’s authorization.
Because both filters read HttpContext.Items["User"] rather than re-validating the token themselves,
[Authorize]/[RoleRequirement] only make sense downstream of AuthenticationMiddleware — see
Architecture for how the two fit together.
Credentials and validation
public record Credentials(string Email, string Password);
CredentialsValidator (FluentValidation) enforces:
Email— not empty, and a valid email address format.Password— not empty, minimum length 8.
public class CredentialsValidator : AbstractValidator<Credentials>
{
public CredentialsValidator()
{
RuleFor(credentials => credentials.Email).NotEmpty().EmailAddress();
RuleFor(credentials => credentials.Password).NotEmpty().MinimumLength(8);
}
}
Authentication — the login result
public record Authentication(string? Token, bool Valid, string CreatedAt, string Expiration);
The record your authentication route returns after checking Credentials: Token is the issued JWT (or
null on failure), Valid indicates whether the attempt succeeded, and CreatedAt/Expiration describe
the token’s lifetime — the values you’d typically use to size access-token lifetime and drive a refresh
flow, especially under ClaimsOnly mode where a short expiration is what keeps role changes and
revocations bounded.
Where to next
- Architecture — the full token →
AuthenticationMiddleware→Items["User"]→ authorization-filter flow, alongside the rest of the pipeline. - Configuration — how
WebApiStartupaddsAuthenticationMiddlewareonceAddTokenAuthenticationis called inConfigureServices. - Middleware & Diagnostics — how
ExceptionMiddlewareandTraceActivityMiddlewarerelate to the rest of the pipelineAuthenticationMiddlewareruns in.
Migrating from 2.x
3.0.0 makes the authenticated-user type and the token’s claim keys caller-defined. Where the library
used to hard-code AuthenticatedUser(int Id, int Role), it now only knows IAuthenticatedUser (Guid Id, int RoleId) — implement your own, or keep using the library’s AuthenticatedUser(Guid Id, int RoleId).
| Before | After |
|---|---|
AuthenticatedUser(int Id, int Role) | AuthenticatedUser(Guid Id, int RoleId), or your own IAuthenticatedUser |
user.Role | user.RoleId |
TokenClaimKeys.Role | TokenClaimKeys.RoleId — the claim string is still "role" |
GetAuthenticatedUserById(int id) returning AuthenticatedUser? | GetAuthenticatedUserById(Guid id) returning IAuthenticatedUser? |
GetAuthenticatedUserByEmail(string email) returning AuthenticatedUser? | same parameter, now returning IAuthenticatedUser? |
TokenValidationResult(AuthenticatedUser?, string?) | TokenValidationResult(IAuthenticatedUser?, string?) — affects custom ITokenValidator implementations |
user.ToTokenClaims() | mapper.ToClaims(user) |
AuthenticatedUserFactory.FromToken(token) | TokenClaimsReader.Read(token) then mapper.FromClaims(claims) |
(AuthenticatedUser?)HttpContext.Items["User"] | HttpContext.GetUser<MyUser>() |
Two things to plan around before you deploy this upgrade:
Tokens issued by 2.x are rejected after the upgrade. The claim key strings are unchanged, so the
claims still read — but DefaultAuthenticatedUserMapper requires the id claim to parse as a Guid,
and 2.x wrote an integer there. Every unexpired access token therefore fails with a 401
("Could not retrieve user from token", or "Could not retrieve user id from token" in Revalidate
mode). This is fail-closed and safe, but it logs every user out at the moment of deployment. Plan for
it: drain the old tokens before switching over, accept the forced re-authentication, or ship a mapper
that accepts both an integer and a Guid in the id claim for one release.
User ids must become Guids. If your store keys users by integer, this is a data migration, not
just a compile fix.