Skip to content

Secure Your Application — End-to-End Hardening Guide

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]

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.

Program.cs
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 errors
app.UseGranitSecurityHeaders(); // 2nd — headers on every response
app.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
}
}

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.

Step 7 — Public surface: CORS, rate limiting, API keys

Section titled “Step 7 — Public surface: CORS, rate limiting, API keys”

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.

{
"Http:Cors": {
"AllowedOrigins": [ "https://app.example.com" ],
"AllowCredentials": true
},
"RateLimiting": {
"Anonymous": { "PermitLimit": 30, "Window": "00:01:00" },
"Authenticated": { "PermitLimit": 300, "Window": "00:01:00" }
}
}

Step 8 — Idempotency for mutating endpoints

Section titled “Step 8 — Idempotency for mutating endpoints”

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: