Skip to content

Security Headers

The Granit.Http.SecurityHeaders module provides centralized HTTP security response header management. It suppresses the Kestrel Server header to prevent server fingerprinting and injects defensive headers on every response per the OWASP Secure Headers Project.

Included in Bundle.Essentials — active by default for all Granit applications.

StandardReference
OWASP Secure Headers ProjectResponse header best practices
OWASP ASVS V14.4HTTP Security Headers
ISO 27001 A.8.9Configuration management
CWE-200Information Exposure
HeaderDefault valuePurpose
Server(removed)Prevent fingerprinting
X-Content-Type-OptionsnosniffPrevent MIME sniffing
X-Frame-OptionsDENYPrevent clickjacking
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForce HTTPS (1 year)
Referrer-Policystrict-origin-when-cross-originControl referrer leakage
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=(), accelerometer=(), gyroscope=(), magnetometer=(), usb=()Restrict browser features
X-XSS-Protection0Disable legacy XSS auditor
Cross-Origin-Opener-Policysame-originSpectre isolation
Cross-Origin-Resource-Policysame-originResource sharing control
Cross-Origin-Embedder-Policy(not set)Optional Spectre isolation
Content-Security-Policydefault-src 'none'; base-uri 'none'; frame-ancestors 'none'Composed per-endpoint from CspOptions base + registered ICspContributor implementations

The module is included in Bundle.Essentials. Add the middleware in Program.cs:

app.UseGranitExceptionHandling(); // 1st — catch errors
app.UseGranitSecurityHeaders(); // 2nd — headers on ALL responses
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

All headers are configurable via appsettings.json:

{
"Http:SecurityHeaders": {
"SuppressServerHeader": true,
"EnableContentTypeOptions": true,
"XFrameOptions": "DENY",
"ReferrerPolicy": "strict-origin-when-cross-origin",
"DisableXssProtection": true,
"PermissionsPolicy": "camera=(), microphone=(), geolocation=(), payment=(), accelerometer=(), gyroscope=(), magnetometer=(), usb=()",
"EnableHsts": true,
"HstsMaxAgeSeconds": 31536000,
"HstsIncludeSubDomains": true,
"HstsPreload": false,
"CrossOriginOpenerPolicy": "same-origin",
"CrossOriginEmbedderPolicy": null,
"CrossOriginResourcePolicy": "same-origin"
}
}

Set the header value to null or the enable flag to false:

{
"Http:SecurityHeaders": {
"XFrameOptions": null,
"EnableContentTypeOptions": false
}
}

CSP in Granit is composed per-endpoint rather than configured as a single application-wide string. The composer starts from a typed base policy (SecurityHeaders:Csp in configuration) and then lets every package that owns a UI surface declare its own relaxations through an ICspContributor — without the consuming application having to know about them.

The base policy is API-grade strict, suitable for JSON-only endpoints:

default-src 'none'; base-uri 'none'; frame-ancestors 'none'

Every other directive is empty by default and inherits from default-src 'none' per CSP semantics. UI surfaces that need relaxations (e.g. Scalar, an admin UI, the BFF login flow) ship a contributor scoped to their own route.

CspOptions exposes one strongly-typed property per directive. Bind from the SecurityHeaders:Csp section:

{
"Http:SecurityHeaders": {
"Csp": {
"DefaultSrc": ["'self'"],
"ScriptSrc": ["'self'", "'unsafe-inline'"],
"FontSrc": ["'self'", "https://fonts.scalar.com"]
}
}
}
PropertyDirective
DefaultSrcdefault-src
ScriptSrc, ScriptSrcElem, ScriptSrcAttrscript-src, script-src-elem, script-src-attr
StyleSrc, StyleSrcElem, StyleSrcAttrstyle-src, style-src-elem, style-src-attr
FontSrc, ImgSrc, ConnectSrc, MediaSrc, ObjectSrc, ManifestSrcfont-src, img-src, connect-src, media-src, object-src, manifest-src
FrameSrc, WorkerSrc, ChildSrcframe-src, worker-src, child-src
BaseUri, FormAction, FrameAncestorsbase-uri, form-action, frame-ancestors
ReportUri, ReportToreport-uri, report-to
ReportOnlyEmit Content-Security-Policy-Report-Only instead of the enforcing header
UpgradeInsecureRequestsEmit upgrade-insecure-requests
RawOverrideEmergency escape hatch — emitted verbatim, contributors skipped

A contributor is the unit of CSP composition: a small class — usually internal sealed — that declares “on routes matching X, add Y to the policy.” The composer collects every registered contributor, runs them against the matched endpoint, merges their additions into the base policy, and emits a single header per response.

The contracts ship in Granit.Http.SecurityHeaders.Abstractions — a zero-runtime-dependency contracts package. A package that wants to declare a relaxation references only the Abstractions package, not the runtime, mirroring the Microsoft.Extensions.Logging.Abstractions pattern.

Example contributor for a hypothetical /admin UI:

internal sealed class AdminUiCspContributor : ICspContributor
{
public void Contribute(HttpContext context, CspBuilder builder)
{
if (context.GetEndpoint()?.Metadata.GetMetadata<AdminUiMetadata>() is null)
{
return;
}
builder
.AddScriptSrc("'self'", "'unsafe-inline'")
.AddStyleSrc("'self'", "'unsafe-inline'");
}
}

Registration happens in the package’s Use* extension, after the activation gate:

public static IApplicationBuilder UseAdminUi(this IApplicationBuilder app)
{
var registry = app.ApplicationServices.GetService<ICspContributorRegistry>();
registry?.Add(new AdminUiCspContributor());
// ... map admin endpoints with WithMetadata(new AdminUiMetadata()) ...
return app;
}

The registry locks on the first call from the composer (i.e. the first request served). Registering a contributor after that throws InvalidOperationException with an actionable message — register during application configuration, not lazily on first use.

Disabling a contributor from configuration

Section titled “Disabling a contributor from configuration”

When an internal policy is stricter than the framework default, opt a contributor out without forking the owning package:

{
"Http:SecurityHeaders": {
"DisabledContributors": ["ScalarCspContributor"]
}
}

Matched against ICspContributor.Name, which defaults to the runtime type name via a default interface method. Override Name only when the type name is not stable across refactors.

Consumers using the Keycloak JS adapter need /silent-check-sso.html to be embeddable in a same-origin iframe. The strict default frame-ancestors 'none' blocks that. Ship a small contributor in the consuming application:

internal sealed class SilentCheckSsoCspContributor : ICspContributor
{
public void Contribute(HttpContext context, CspBuilder builder)
{
if (!context.Request.Path.Equals(
"/silent-check-sso.html",
StringComparison.OrdinalIgnoreCase))
{
return;
}
builder.AddFrameAncestors("'self'");
}
}
var registry = app.Services.GetService<ICspContributorRegistry>();
registry?.Add(new SilentCheckSsoCspContributor());

This relaxes frame-ancestors only on that one route; the rest of the app keeps the strict default. For modern browsers, CSP frame-ancestors supersedes the legacy X-Frame-Options — the global XFrameOptions: "DENY" setting stays unchanged.

Granit.Http.SecurityHeaders.Endpoints ships a single audit endpoint so security auditors can introspect the effective per-route CSP from outside the deployment. The package is opt-in — reference it explicitly and map the endpoint in Program.cs:

app.MapGranitSecurityHeadersAudit();
// GET /security-headers/csp
// Gated by DiagnosticsPermissions.Monitoring.Read

The endpoint reuses the existing Diagnostics permission — no new permission, no new localisation. Returns a JSON snapshot shaped like:

{
"baseDirectives": {
"default-src": ["'none'"],
"base-uri": ["'none'"],
"frame-ancestors": ["'none'"]
},
"rawOverride": null,
"reportOnly": false,
"contributors": [
{ "name": "ScalarCspContributor", "fullTypeName": "Granit.Http.ApiDocumentation.Internal.ScalarCspContributor" }
],
"endpoints": [
{
"httpMethods": ["GET"],
"pattern": "/scalar",
"displayName": "Scalar",
"headerName": "Content-Security-Policy",
"composedCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; ..."
}
]
}

To submit your domain to the HSTS Preload List:

{
"Http:SecurityHeaders": {
"HstsPreload": true,
"HstsIncludeSubDomains": true,
"HstsMaxAgeSeconds": 31536000
}
}

COEP is not set by default because it can break cross-origin resources (fonts, images, scripts). Enable it only when all cross-origin resources include Cross-Origin-Resource-Policy:

{
"Http:SecurityHeaders": {
"CrossOriginEmbedderPolicy": "require-corp"
}
}

The Server header suppression applies only when the application runs on Kestrel. When deployed behind a reverse proxy (nginx, Envoy, YARP), the proxy manages its own Server header. Ensure the proxy is also configured to suppress it.