Changelog v3.13.0 (2026-04-24)
Version 3.13.0 (2026-04-24)
New: Auth Schemes (Named Additional Authentication Schemes)
Auth:Schemes is a named-dict section that registers additional ASP.NET Core authentication schemes alongside the main one. Each entry is a fully-fledged scheme of any of the three supported types — Cookies, BearerToken, or Jwt — with its own options. A login function selects which scheme to use by returning the scheme's name in its scheme column.
Use cases this unlocks:
- Short-lived sensitive sessions for admin or payment flows (Cookies scheme with shorter
CookieValid+CookieMultiSessions: false). - Per-scope JWT signing keys so a key leak has limited blast radius (separate
JwtSecretper Jwt scheme). - Multiple bearer-token APIs with different expirations and refresh paths.
- Single-session cookies for areas where parallel logins must be disallowed, alongside a normal long-lived session.
jsonc
"Auth": {
"CookieAuth": true,
"CookieValid": "14 days",
"JwtAuth": true,
"JwtSecret": "...root-secret-32+chars...",
"Schemes": {
"short_session": {
"Type": "Cookies",
"Enabled": true,
"CookieValid": "1 hour",
"CookieMultiSessions": false
},
"api_token": {
"Type": "BearerToken",
"Enabled": true,
"BearerTokenExpire": "30 minutes",
"BearerTokenRefreshPath": "/api/api-token/refresh"
},
"admin_jwt": {
"Type": "Jwt",
"Enabled": true,
"JwtSecret": "...separate-admin-secret-32+chars...",
"JwtExpire": "5 minutes",
"JwtRefreshPath": "/api/admin-jwt/refresh"
}
}
}sql
-- Standard login: returns 'Cookies' → 14-day persistent cookie
create function login(_user text, _pass text)
returns table (scheme text, name_identifier text, name text)
language sql security definer as $$
select 'Cookies' as scheme, user_id::text, username from users where ...
$$;
-- Sensitive-area login: returns 'short_session' → 1-hour session-only cookie
create function admin_login(_user text, _pass text)
returns table (scheme text, name_identifier text, name text)
language sql security definer as $$
select 'short_session' as scheme, user_id::text, username from users where ...
$$;
-- Admin JWT login: returns 'admin_jwt' → 5-minute JWT signed with the admin-only secret
create function admin_jwt_login(_user text, _pass text)
returns table (scheme text, name_identifier text, name text)
language sql security definer as $$
select 'admin_jwt' as scheme, user_id::text, username from users where ...
$$;Per-type override fields:
| Type | Override fields |
|---|---|
Cookies | CookieValid, CookieName, CookiePath, CookieDomain, CookieMultiSessions, CookieHttpOnly |
BearerToken | BearerTokenExpire, BearerTokenRefreshPath |
Jwt | JwtExpire, JwtRefreshExpire, JwtSecret, JwtIssuer, JwtAudience, JwtClockSkew, JwtRefreshPath, JwtValidateIssuer, JwtValidateAudience, JwtValidateLifetime, JwtValidateIssuerSigningKey |
Common fields: Type (required, case-insensitive), Enabled (default true).
Inheritance. A scheme that overrides only one or two fields reuses everything else from the root Auth section, so blocks stay small. Setting CookieMultiSessions: false is the typical "single-session" override — the cookie's Max-Age becomes null (browser-session-only) while ExpireTimeSpan still bounds server-side validity. JWT schemes inherit JwtSecret from the root section if not set explicitly, so a per-scheme block can be just a shorter expiration.
Validation at startup (fail-fast).
- Scheme name must not collide with the main scheme names (
CookieAuthScheme,BearerTokenAuthScheme,JwtAuthScheme). Typemust be one ofCookies,BearerToken,Jwt(case-insensitive). Missing or unsupported types throw with a clear message.- Explicit
CookieNamevalues must be distinct across all cookie schemes. When unset, ASP.NET's per-scheme.AspNetCore.<scheme>default automatically differs and is excluded from collision tracking. - Refresh paths (
BearerTokenRefreshPath/JwtRefreshPath) must be unique across the main scheme and every scheme that defines one — twoapp.Usemiddlewares listening on the same path would race. - Jwt schemes require a secret either on the scheme or on the root section;
JwtSecretmust be ≥32 chars for HS256. - Invalid interval strings (
CookieValid,BearerTokenExpire,JwtExpire,JwtRefreshExpire,JwtClockSkew) throw with the offending path and value.
Refresh middleware per scheme. Each BearerToken/Jwt scheme that declares a refresh path gets its own app.Use middleware listening on that path, with that scheme's tokens validated under that scheme's options. The main-scheme refresh middleware (root BearerTokenRefreshPath / JwtRefreshPath) continues to work for the main scheme.
Logout. The existing logout pipeline accepts a list of scheme names from the logout function's result columns and signs out each — additional schemes work without changes. To clear both main and additional cookies in one logout, return both scheme names from the function.
Breaking: legacy auth time-integer fields removed
The four integer-based time fields under Auth are removed in 3.13.0. Use the interval-notation equivalents instead:
| Removed (3.12 and earlier) | Use instead (3.13.0+) |
|---|---|
Auth:CookieValidDays: 14 | Auth:CookieValid: "14 days" |
Auth:BearerTokenExpireHours: 1 | Auth:BearerTokenExpire: "1 hour" |
Auth:JwtExpireMinutes: 60 | Auth:JwtExpire: "60 minutes" |
Auth:JwtRefreshExpireDays: 7 | Auth:JwtRefreshExpire: "7 days" |
The new fields accept Postgres-interval syntax ("14 days", "12 hours", "30 minutes", "45 seconds", etc.) — finer-grained durations than the legacy integers permitted.
If you upgrade with any of the four removed fields still in your config, startup will fail with a clear migration message naming the offending field, the replacement field, and an example interval string. Failing fast is deliberate — silently ignoring the legacy field would mean an "I configured 30-day cookies" intent silently flips to the new field's default of 14 days, which would be very surprising.
New: interval notation for auth time fields
Each of the four time-window settings under Auth (cookie validity, bearer-token expiration, JWT access-token expiration, JWT refresh-token expiration) is expressed as a Postgres interval string:
jsonc
"Auth": {
"CookieAuth": true,
"CookieValid": "12 hours",
"JwtAuth": true,
"JwtExpire": "5 minutes",
"JwtRefreshExpire": "1 day"
}Setting any of these to null falls back to the framework default (14 days / 1 hour / 60 minutes / 7 days respectively). Invalid interval values fail at startup with a clear error, rather than silently falling back. The shipped appsettings.json includes the explicit defaults so users see exactly what they're getting.
Breaking: RateLimiterOptions:Policies is now a dict, not an array
This section was previously an array of objects, each with an explicit "Name" property. It is now an object keyed by policy name, matching the existing ValidationOptions:Rules and the new CacheOptions:Profiles shape. Migration is mechanical:
jsonc
// Before (3.12 and earlier):
"Policies": [
{ "Name": "fixed", "Type": "FixedWindow", "Enabled": true, "PermitLimit": 100, ... },
{ "Name": "sliding", "Type": "SlidingWindow", "Enabled": false, ... }
]
// After (3.13.0):
"Policies": {
"fixed": { "Type": "FixedWindow", "Enabled": true, "PermitLimit": 100, ... },
"sliding": { "Type": "SlidingWindow", "Enabled": false, ... }
}Move each policy's Name value to be the JSON key, then drop the Name field. No other field changes; runtime behavior is identical.
If you upgrade with the old array form still in your config, startup will fail with a clear InvalidOperationException telling you to migrate. (Failing fast is deliberate — silently registering policies under names like "0" and "1" would have made endpoint annotations referencing them stop matching, leading to silent loss of rate limiting.)
New: Per-User Rate Limiting (Partition on a policy)
Rate-limiter policies can now be partitioned at request time, so each request gets its own bucket based on a value derived from HttpContext (a claim, an IP, a header, or a static fallback). The classic use case is per-user throttling: each authenticated user gets their own quota instead of all users sharing one global bucket.
jsonc
"RateLimiterOptions": {
"Enabled": true,
"Policies": {
"per_user": {
"Type": "FixedWindow",
"Enabled": true,
"PermitLimit": 100,
"WindowSeconds": 60,
"Partition": {
"Sources": [
{ "Type": "Claim", "Name": "name_identifier" },
{ "Type": "IpAddress" },
{ "Type": "Static", "Value": "anonymous" }
]
}
},
"throttle_anon_only": {
"Type": "FixedWindow",
"Enabled": true,
"PermitLimit": 10,
"WindowSeconds": 60,
"Partition": {
"BypassAuthenticated": true,
"Sources": [{ "Type": "IpAddress" }]
}
}
}
}Partition fields:
Sources— ordered list. Walked top-to-bottom at request time; the first source returning a non-empty value wins. Each source has aType:Claim— readsHttpContext.User.FindFirst(Name).Value.Nameis required (the claim type, e.g.,"name_identifier").IpAddress— reads the client IP viaHttpRequest.GetClientIpAddress(), which honorsX-Forwarded-For/X-Real-IPahead ofConnection.RemoteIpAddress. NoNameneeded.Header— readsHttpContext.Request.Headers[Name].Nameis required.Static— always returns the configuredValue. Useful as a terminal fallback (e.g., everyone unmatched shares the"anonymous"bucket).
If no source resolves, partition resolution falls through to the literal key
"unpartitioned"so the policy still rate-limits coherently.BypassAuthenticated(bool, defaultfalse) — whentrue, signed-in users (HttpContext.User.Identity.IsAuthenticated) skip the limiter entirely. Evaluated beforeSources, so use this for "throttle anonymous only" patterns. Authenticated users get an unlimited bucket; anonymous users still hit the partitioned limiter.
Behavior is unchanged for policies without a Partition block. Each non-partitioned policy still uses a single global bucket, exactly as in 3.12 and earlier.
Each Sources entry is validated at startup — invalid entries (e.g., Claim without Name, unknown Type) are logged at Warning and skipped. If a Partition block has no usable sources and BypassAuthenticated is false, the partition is dropped (with a Warning) and the policy reverts to a single global bucket.
New: Caching Profiles (CacheOptions.Profiles + @cache_profile annotation)
Named cache profiles allow you to maintain multiple distinct caching policies in one application — different backends, expirations, key shapes, or bypass conditions — and let endpoints opt into them via a single comment annotation.
jsonc
"CacheOptions": {
"Enabled": true,
"Type": "Memory",
"Profiles": {
"fast_memory": {
"Enabled": false,
"Type": "Memory",
"Expiration": "30 seconds",
"Parameters": ["user_id"]
},
"shared_redis": {
"Enabled": false,
"Type": "Redis",
"Expiration": "1 hour"
},
"date_range_hybrid": {
"Enabled": false,
"Type": "Hybrid",
"Expiration": "5 minutes",
"Parameters": ["from", "to"],
"When": [
{ "Parameter": "to", "Value": null, "Then": "skip" }
]
}
}
}sql
comment on function get_orders(from text, to text) is '
HTTP GET
@cache_profile date_range_hybrid
';Profile fields:
Enabled(bool, defaultfalse) — disabled profiles are skipped at startup; flip totrueto activate.Type—Memory,Redis, orHybrid. Backends are pooled: all profiles of the same type share one instance (one Memory cache, one Redis connection, one HybridCache singleton). A backend type is only instantiated if root or some enabled profile uses it.Expiration— default expiration in PostgreSQL interval format. Used when the endpoint has no@cache_expiresannotation.Parameters— default cache-key parameter list:null(or property omitted): use all routine parameters.[](empty array): URL-only cache (one entry per endpoint, regardless of inputs).["x", "y"]: only those named parameters as the key.
The endpoint's
@cached p1, p2annotation overrides this.When— list of conditional rules. Each rule has:Parameter— the routine parameter name to inspect.Value— match condition. Scalar (single match) or array (OR over entries). JSONnullmatches .NETnull/DBNull.Value(does not match empty string). Other values are stringify-and-equal case-insensitive.Then— the literal string"skip"to bypass the cache for that request, OR a PostgreSQL interval (e.g."30 seconds") to override the entry's TTL when writing.
Rules evaluate in declaration order; first match wins. No match → fall through to the profile's
Expiration.This unlocks scenarios that pure skip-on-condition couldn't express:
- Skip-on-null:
[{ "Parameter": "to", "Value": null, "Then": "skip" }] - Tiered TTL:
[{ "Parameter": "tier", "Value": "free", "Then": "5 minutes" }, { "Parameter": "tier", "Value": "pro", "Then": "1 hour" }] - Status-aware caching:
[{ "Parameter": "status", "Value": ["draft", null], "Then": "skip" }, { "Parameter": "status", "Value": "published", "Then": "1 hour" }]
Validation: a rule whose
Parameteris not in the resolved cache-key parameter list (Parametersor the endpoint's@cached) is dropped at startup with a Warning. This prevents the surprising case where two requests with different rule-matched values share the same cache entry.
Annotation: @cache_profile <name> selects a profile. It implies caching even without a separate @cached annotation. The existing @cached p1, p2 (overrides profile params) and @cache_expires <interval> (overrides profile expiration) annotations continue to work and take precedence over the profile's defaults.
Misconfiguration is loud at startup. Unknown profile names referenced by @cache_profile cause startup to fail with a single InvalidOperationException listing every unresolved name and the endpoints that referenced each — so typos surface immediately rather than silently disabling caching at runtime. Profiles registered but unreferenced log an Information warning. Bad Type or Expiration values warn and skip the profile. Empty/whitespace profile names are rejected.
Cache key isolation. Entries written under a profile are prefixed with the profile name, so two profiles sharing the same backend (e.g., two Memory profiles) cannot collide on the same routine + parameters. Endpoints without a profile have no prefix — existing cache entries stay wire-compatible across the upgrade.
The default appsettings.json ships with three disabled example profiles covering each Type and demonstrating a When rule. Flip "Enabled": true on the one(s) you need.
Never-expiring (infinite) cache entries
There is no explicit "forever" or "never" literal — instead, omit the Expiration field to get never-expiring entries. This applies at every level:
- Endpoint without
@cache_expires→ entry never expires (today's pre-3.13 behavior, unchanged). - Profile without
Expirationfield → entries written under that profile never expire. - Both
@cache_expiresand profileExpirationset → annotation wins (the explicit interval is used).
If you need a mix in one app — some profiles with TTL, others never-expiring — define a dedicated profile with no Expiration:
jsonc
"Profiles": {
"static_lookup_data": {
"Enabled": true,
"Type": "Memory"
// No Expiration → entries never expire (suitable for ISO codes, taxonomies, etc.)
},
"session_data": {
"Enabled": true,
"Type": "Redis",
"Expiration": "1 hour"
}
}Endpoints opt into the appropriate profile via @cache_profile. This pattern handles the common cases (static reference data, immutable historical content) without needing a separate "force never expire" override.
New: WrapInTransaction Option (Connection Pooler Compatibility)
When set to true, every request is wrapped in an explicit BEGIN ... COMMIT, and all set_config calls switch from session-scoped (is_local=false) to transaction-local (is_local=true).
This is required for connection poolers in transaction mode — including PgBouncer transaction-pool, AWS RDS Proxy in transaction mode, and Supabase Pooler. Previously, set_config(name, value, false) would set the GUC at the session level on the underlying PostgreSQL backend. With a transaction-mode pooler, the same backend is reused for unrelated client requests, which means session-scoped GUCs from one request could be visible to the next. With WrapInTransaction = true, GUCs are scoped to the request transaction and discarded on COMMIT.
The default remains false to preserve existing behavior; it is safe to leave off when using Npgsql's native pool only (which issues DISCARD ALL on connection return).
jsonc
{
"NpgsqlRest": {
"WrapInTransaction": true
}
}New: BeforeRoutineCommands Option
A new option allowing arbitrary SQL commands to run after any context is set but before the main routine call. They run in the same batch as the context set_config calls, so there is no extra network round-trip.
Each entry can be either a raw SQL string (no parameters) or an object with Sql and Parameters. Each parameter has a Source (Claim, RequestHeader, or IpAddress) and an optional Name (claim type or header name). Parameter values are bound at request time from HttpContext — claim and header values are passed as parameterized SQL inputs (no string interpolation, no injection risk).
The most useful pattern is multi-tenant search_path setup driven by a JWT/cookie claim:
jsonc
{
"NpgsqlRest": {
"WrapInTransaction": true,
"BeforeRoutineCommands": [
"select set_config('app.request_time', clock_timestamp()::text, true)",
{
"Sql": "select set_config('search_path', $1, true)",
"Parameters": [{ "Source": "Claim", "Name": "tenant_id" }]
}
]
}
}Per-request execution order with this config:
BEGIN- Each
BeforeRoutineCommandis added as aNpgsqlBatchCommand(with parameters bound from claims/headers/IP) and dispatched in a single batch. - The main routine call.
COMMIT.
Steps 1–3 share a single network round-trip.
Fix: 400 Bad Request responses are no longer silent in logs
Endpoints that returned HTTP 400 were not being logged at all, making client-error problems invisible in production. Two independent paths produced silent 400s:
- Database exceptions mapped to 400 (
P0001 raise exception,P0004 assert_failure, or any user-configuredErrorHandlingOptionsmapping to 400). The exception handler inNpgsqlRestEndpointexplicitly skipped logging for status400. - Validation rule failures (
ValidationOptions.Rules→ 400). These were logged atDebuglevel, which is below the default minimum log level (Information), so they never appeared in production logs.
Fix: 400s are now logged at Warning level — visible by default but not raised to Error, since 400 is a client-side problem rather than a server fault. Genuine server errors (500, etc.) continue to be logged at Error with full stack traces.
Docker Images: Ubuntu 26.04 LTS Base
The native AOT Docker images (latest, latest-arm, latest-bun) now build on Ubuntu 26.04 "Resolute Wolf" LTS, up from Ubuntu 25.04 (a 9-month interim release that is reaching end of support). This extends the security-update window for the published images to the 5-year LTS support period and picks up a newer stack (Linux 7.0 kernel, newer OpenSSL, cgroup v2). No changes are required for consumers of the images — runtime dependencies (libssl3, libgssapi-krb5-2, ca-certificates) resolve under the same package names on 26.04.
NuGet Package Upgrades
NpgsqlRest (main library):
Microsoft.SourceLink.GitHub10.0.201 → 10.0.203 (build-time only)
NpgsqlRestClient (client application):
Microsoft.AspNetCore.Authentication.JwtBearer10.0.5 → 10.0.7Microsoft.Extensions.Caching.Hybrid10.4.0 → 10.5.0Microsoft.Extensions.Caching.StackExchangeRedis10.0.5 → 10.0.7StackExchange.Redis2.12.8 → 2.12.14