CORS
Granit.Http.Cors — restrict allowed origins
to known SPAs. Never use AllowAnyOrigin() with credentialed requests.
This guide walks through the order in which you should harden a Granit application,
from a fresh dotnet new project to a production-ready deployment. Each step is
self-contained, links out to the reference page for the module it configures, and
ends with a pointer to the next layer.
The Security Overview page is a lookup table (“which module solves problem X”). This guide is the opposite: a recommended order of operations with the code for each step.
Granit’s security primitives stack like an onion — each layer assumes the outer layers are present, and each one mitigates a different class of threat. Skipping a layer doesn’t break the next one, but it leaves a known gap.
graph TD
A[1. Transport — HTTPS + HSTS] --> B[2. Browser — CSP + security headers]
B --> C[3. Authentication — who is calling]
C --> D[4. Authorization — what they can do]
D --> E[5. Session — BFF for SPAs]
E --> F[6. Data at rest — Vault + field-level encryption]
F --> G[7. Surface — CORS, rate limiting, API keys]
G --> H[8. Replay safety — idempotency]
H --> I[9. Audit trail — who did what, when]
I --> J[10. Tenant isolation — query filters]
J --> K[11. Go-live — production checklist]
granit-microservice-template — the template wires
Bundle.Essentials for you, which already activates security headers and
exception handling.Plaintext HTTP is the only failure mode that invalidates every subsequent layer: an attacker on the wire can strip auth headers, replay cookies, and inject content before the browser ever sees a CSP. Enforce HTTPS at the Kestrel level and disable HTTP/1.0 fallbacks.
builder.WebHost.ConfigureKestrel(opts =>{ opts.ConfigureEndpointDefaults(e => e.Protocols = HttpProtocols.Http1AndHttp2);});
var app = builder.Build();
app.UseHttpsRedirection();In appsettings.Production.json, force JWT metadata over HTTPS so token
discovery cannot be downgraded:
{ "Authentication": { "RequireHttpsMetadata": true }}HSTS is set automatically by Granit.Http.SecurityHeaders
in the next step — you do not need app.UseHsts().
Bundle.Essentials already registers
Granit.Http.SecurityHeaders. The
middleware emits HSTS, X-Frame-Options: DENY, Referrer-Policy,
Permissions-Policy, X-Content-Type-Options: nosniff, and removes the Kestrel
Server header. The only thing you typically need to add yourself is the CSP
policy for any UI surface your API serves.
app.UseGranitExceptionHandling(); // 1st — catches errorsapp.UseGranitSecurityHeaders(); // 2nd — headers on every responseapp.UseRouting();app.UseAuthentication();app.UseAuthorization();The default CSP is API-grade strict (default-src 'none'; base-uri 'none'; frame-ancestors 'none'). If you serve Scalar, an admin UI, or any browser
surface, ship an ICspContributor scoped
to that route rather than relaxing the global policy:
public sealed class ScalarCspContributor : ICspContributor{ public bool AppliesTo(HttpContext ctx) => ctx.Request.Path.StartsWithSegments("/scalar");
public void Contribute(CspBuilder csp) => csp .StyleSrc("'self'", "'unsafe-inline'") .ScriptSrc("'self'");}Pick the provider that matches your identity platform and add the matching
package. The
ICurrentUserService
contract is identical across providers — switching from Keycloak to Entra ID
only changes one module reference.
[DependsOn(typeof(GranitAuthenticationJwtBearerKeycloakModule))]public sealed class AppModule : GranitModule { }{ "Authentication": { "Authority": "https://keycloak.example.com/realms/granit", "Audience": "granit-api", "RequireHttpsMetadata": true }}[DependsOn(typeof(GranitAuthenticationJwtBearerEntraIdModule))]public sealed class AppModule : GranitModule { }[DependsOn(typeof(GranitAuthenticationJwtBearerCognitoModule))]public sealed class AppModule : GranitModule { }Detailed provider wiring — including back-channel logout, OIDC flows, and DPoP-bound tokens — lives on the Authentication page. If you self-host the IdP, see OpenIddict.
Authentication tells you who is calling. Authorization tells you what they
can do. Granit’s Granit.Authorization
module ships a dynamic policy provider so permissions are not baked into
attributes — they are evaluated at request time against a per-tenant permission
store.
[DependsOn(typeof(GranitAuthorizationModule))]public sealed class AppModule : GranitModule { }app.MapPost("/invoices", CreateInvoice) .RequireAuthorization("invoices.create");Permissions are defined declaratively per module and assigned to roles via the Authorization Roles APIs. For multi-tenant apps, the multi-tenancy side authorization page covers per-tenant permission stores. For service-to-service calls, see Inter-service Authorization.
If your API is consumed by an SPA, do not hand access tokens to the browser.
Tokens in localStorage are an XSS exfiltration target; tokens in
Authorization headers travel through JavaScript that any third-party script
can read. The Granit.Bff module keeps tokens
server-side and exchanges them for a same-site session cookie.
[DependsOn(typeof(GranitBffModule))]public sealed class AppModule : GranitModule { }The BFF terminates the OIDC flow, stores tokens in a server-side session, and proxies API calls via YARP — see BFF Architecture, Configuration, and Token Security for the full pattern.
On the SPA side, the Frontend Authentication and Frontend Cookies pages cover the React integration and the cookie-consent flow required by GDPR.
Plaintext secrets in appsettings.json are the most common production leak.
Wire a vault provider so connection strings, JWT signing keys, and encryption
passphrases live outside the deployment artifact.
[DependsOn(typeof(GranitVaultHashiCorpModule))]public sealed class AppModule : GranitModule { }See Vault & Encryption for dynamic database credentials, lease renewal, and the Azure Key Vault equivalent.
For PII at rest — national IDs, health records, payment metadata — use
field-level encryption via the [Encrypted]
attribute. The Encrypt Sensitive Data guide
walks through the AES-256 local path and the Vault Transit production path
side by side.
public sealed class Patient : FullAuditedEntity<Guid>{ public string Name { get; set; } = string.Empty;
[Encrypted] public string NationalId { get; set; } = string.Empty;}For irreversible deletion (GDPR Art. 17 right to erasure on a per-tenant key), see Crypto-Shredding.
The three controls every public-facing endpoint needs:
CORS
Granit.Http.Cors — restrict allowed origins
to known SPAs. Never use AllowAnyOrigin() with credentialed requests.
Rate limiting
Granit.Http.RateLimiting — protect login, password
reset, and any unauthenticated endpoint from credential stuffing and scraping.
API keys
Granit.Authentication.ApiKey — service-to-service
calls with rotation, expiry scanning, and lifecycle notifications.
{ "Http:Cors": { "AllowedOrigins": [ "https://app.example.com" ], "AllowCredentials": true }, "RateLimiting": { "Anonymous": { "PermitLimit": 30, "Window": "00:01:00" }, "Authenticated": { "PermitLimit": 300, "Window": "00:01:00" } }}Network retries are the most common cause of duplicate orders, double-charged
invoices, and duplicate webhook deliveries. Granit’s
Idempotency-Key middleware deduplicates POST and
PUT requests by client-provided key — see the
Configure Idempotency guide for the
two-line wiring and the rationale in
Idempotency keys: why every POST should be retry-safe.
Not strictly a security control, but it closes a real exploit window: without it, a hostile retry of a successful purchase is indistinguishable from a fresh purchase.
Every regulated framework (ISO 27001 A.12.4, GDPR Art. 30, NIS2 Art. 21) requires
a tamper-evident record of who did what, when, and from where. Granit’s
AuditedEntityInterceptor populates CreatedBy,
ModifiedBy, and DeletedBy automatically from
ICurrentUserService.
public sealed class Invoice : FullAuditedEntity<Guid>{ public string CustomerName { get; set; } = string.Empty; public decimal Amount { get; set; }}For change history at the field level (regulated audit trails), see the Implement Audit Timeline guide and the Audit Log compliance page. Background on the interceptor stack lives in Soft deletes, audit trails, query filters.
If your application is multi-tenant, a single missing WHERE TenantId = @id
clause leaks one tenant’s data to another — the worst class of bug in a SaaS.
Granit’s Configure Multi-Tenancy guide
shows the three isolation strategies (shared DB with row filter, schema per
tenant, database per tenant) and the
ApplyGranitConventions global query filter that
enforces row-level isolation automatically.
The interceptor stack relies on
ICurrentTenant being populated before the DbContext
is resolved — which means tenant resolution must run after authentication and
before any data-access middleware.
Once every layer is wired, run through the Production Checklist — it converts the eleven steps above into a concrete tick-list mapped to GDPR, ISO 27001, and operational gates. Treat it as the gate between staging and production, not as a post-incident retrospective.
For the compliance narrative behind these controls: