Skip to documentation
Heimdall Docs
Heimdall Docs

Security

Heimdall is HTML-first and server-driven, but that does not reduce the need for security boundaries. This page explains the core ones: antiforgery, trusted markup, runtime response handling, and what Heimdall intentionally sanitizes or does not sanitize.

The important idea is that Heimdall keeps the browser small, but it does not make security disappear. It makes the trust boundaries easier to reason about.

1. Security in the Heimdall Model

Heimdall actions are still server-side HTTP endpoints. They accept requests, bind payload, and return HTML. That means normal web security concerns still apply: request authenticity, output trust, and the distinction between safe application-owned markup and untrusted content.

Text
Browser interaction
-> Heimdall request
-> server action
-> HTML response
-> DOM update

Security boundaries still matter at every step

2. Why Antiforgery Is the Default

Heimdall actions can mutate or reveal application state through ordinary HTTP calls. Antiforgery is therefore enabled by default and is especially important when authentication rides on ambient cookies. Applications with a different CSRF-safe security design can opt out deliberately. When authentication and antiforgery are both enabled, run authentication first so tokens are issued and validated against the current user.

C#
// Default-enabled Heimdall setup
// If your app uses authentication:
builder.Services.AddAuthentication(/* scheme */);
builder.Services.AddAuthorization();

builder.Services.AddAntiforgery();

var app = builder.Build();

// Keep auth before antiforgery when both are present.
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.UseHeimdall();

3. What the Server Enforces When Enabled

While antiforgery is enabled for an action, the Heimdall content endpoint validates the token before invoking the action. A missing or invalid token is rejected before payload binding and before application code runs.

Text
var antiforgery = ctx.RequestServices.GetRequiredService<IAntiforgery>();
await antiforgery.ValidateRequestAsync(ctx);

This is the default request gate. A per-action or declaring-type metadata override can skip it narrowly, while the global setting can turn it off for the entire Heimdall runtime.

4. How the Runtime Gets Tokens When Enabled

The runtime does not expect every trigger boundary to carry its own hidden antiforgery field. While browser antiforgery is enabled, it fetches a request token from the Heimdall security endpoint, caches it client-side, and sends it with protected action and Bifrost token requests.

Text
GET /__heimdall/v1/csrf
-> requestToken returned
-> runtime caches token
-> token sent on later Heimdall requests

5. Why the Default Matters for Cookie Apps

Returning HTML rather than JSON does not make request authenticity less important. Heimdall actions are still stateful server endpoints. Cookie-authenticated applications should normally keep the default antiforgery policy because browsers attach ambient cookies to requests automatically.

Heimdall actions are still POST requests.
Those requests may change state or expose sensitive UI.
Same-origin alone is not enough.
For ambient cookie authentication, antiforgery is the default protection boundary.

6. Retry Behavior When Enabled

When browser antiforgery is enabled, the runtime treats a suspected CSRF failure as a recoverable token problem once. If a content action or Bifrost subscribe-token request returns a 400 response mentioning CSRF or antiforgery, the runtime clears its cached token, fetches a fresh one, and retries one time. With Heimdall.config.antiforgery set to false, no token fetch or CSRF retry occurs.

Text
request fails with 400
-> response suggests csrf / antiforgery failure
-> cached token cleared
-> token fetched again
-> request retried once

7. Bifrost Follows the Global Policy

By default, Heimdall applies antiforgery to the Bifrost subscribe-token endpoint as well as content actions. A per-action metadata override does not affect Bifrost. Only the global EnableAntiforgery setting disables that check; signed topic-bound subscribe tokens and topic authorization remain independent.

Text
EnableAntiforgery = true
GET /__heimdall/v1/bifrost/token?topic=...
+ antiforgery validation
-> short-lived subscribe token returned

EnableAntiforgery = false
-> antiforgery validation skipped
-> signed subscribe token + authorization still apply

8. Action and Topic Authorization

Heimdall content actions honor ASP.NET Core authorization metadata, and Bifrost topic subscriptions can be gated before a subscribe token is issued. Use [Authorize] and [AllowAnonymous] for actions; use BifrostTopicPolicy or AuthorizeBifrostTopic for topic-level checks. If cookie authentication challenges a content action or Bifrost subscribe-token request, Heimdall treats the sign-in redirect as a full-page navigation and rewrites the return URL parameter to the page that launched the interaction instead of an internal Heimdall endpoint.

JSON
[Authorize(Roles = "Admin")]
[ContentInvocation("admin.refresh")]
public static IHtmlContent RefreshAdminPanel(HttpContext ctx)
{
    return AdminPanel.Render(ctx.User);
}

builder.Services.AddHeimdall(options =>
{
    options.BifrostTopicPolicy = "BifrostTopic";
    options.AuthorizeBifrostTopic = (ctx, topic) =>
        ValueTask.FromResult(topic.StartsWith("user:"));
});

public sealed class TopicHandler
    : AuthorizationHandler<TopicRequirement, BifrostTopicResource>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        TopicRequirement requirement,
        BifrostTopicResource resource)
    {
        var topic = resource.Topic;
        var user = resource.HttpContext.User;
        return Task.CompletedTask;
    }
}

Auth redirect handling:
- content actions watch for followed redirects
- Bifrost token fetches watch for followed redirects
- 401 / 403 responses with Location are also handled
- ReturnUrl matching is case-insensitive
- the original parameter casing is preserved

/signin?returnUrl=/__heimdall/v1/content/actions
-> /signin?returnUrl=/current-page

9. Runtime HTML Sanitization

The Heimdall runtime deliberately strips script tags from action responses before applying them to the DOM. It also removes response directives such as invocation, abort, and redirect elements after those instructions have been processed. This is not a full HTML sanitizer, but a targeted protection for runtime DOM insertion.

Text
response HTML received
-> script tags removed
-> invocation / abort / redirect directives processed
-> directive nodes removed
-> sanitized HTML applied by swap

This is an important guardrail because the runtime is inserting returned HTML into a live document rather than navigating to a whole new page.

10. What Gets Sanitized

In practical terms, Heimdall’s runtime sanitization applies to HTML coming back from Heimdall interaction responses: normal action responses, error responses that are surfaced back through the runtime, and OOB payload fragments before they are inserted.

Text
Sanitized by the runtime:
- action response HTML
- OOB payload fragments
- error HTML flowing through Heimdall response handling

11. What Does Not Get Sanitized

The runtime does not sanitize the initial page load HTML. A full page response is treated as ordinary server-rendered application output. If the server sends a script tag on initial page render, that is a normal page-level trust decision, not something the Heimdall runtime intercepts or rewrites afterward.

Text
Initial page load:
- normal browser HTML parsing
- not sanitized by the Heimdall runtime

Heimdall response swap:
- sanitized by the Heimdall runtime before insertion

12. Why That Distinction Exists

An initial page load is already inside the ordinary server-rendering trust model of the application. Heimdall is not acting as a browser sandbox for full page HTML. Its sanitization exists specifically for runtime-inserted response content that would otherwise be injected into an already-live document.

Full page render is a server trust decision.
Heimdall response insertion is a runtime trust boundary.
The runtime protects the insertion boundary, not the entire web application.

13. Trusted Markup on the Server

The StaticAssets helper highlights an important server-side trust boundary. It returns IHtmlContent and writes raw markup directly to the response without encoding. This is intentionally treated as trusted markup by the server.

Text
container.Add(StaticAssets.Get("fragments/home/hero.html"));

// Writes raw markup directly into the response

14. Server Trust vs Runtime Insertion

Trusted markup on the server does not automatically mean unrestricted DOM insertion on the client. If markup produced by StaticAssets is returned through a Heimdall content invocation, it will still pass through the runtime's response handling before being inserted into the DOM.

Text
StaticAssets -> server emits raw HTML

If used in full page render:
-> sent directly to browser
-> normal page trust model applies

If used in Heimdall response:
-> response handled by the Heimdall runtime
-> script tags removed before insertion

15. Why TrustedMarkup Is Contextual

TrustedMarkup is safe when the file is application-owned and treated like source code. However, its behavior depends on how the markup is delivered. Server-side trust controls encoding, while the runtime controls insertion behavior when the markup is returned through Heimdall.

Text
Safe usage:
- app-owned fragments
- known markup shipped with the app

Context matters:
- full page render -> no runtime sanitization
- Heimdall response -> runtime sanitization applies

Not safe:
- user HTML
- untrusted third-party markup

16. Templates Do Not Remove the Need for Trust Decisions

The same idea applies to templates such as Scriban. A template is only as safe as its inputs and how you choose to render its output. Heimdall does not treat 'template output' as automatically trusted or automatically untrusted. That remains a server-side decision.

Text
Template source + data
-> rendered output
-> server decides whether that output is safe to emit

17. OOB Safety Boundaries

Out-of-band updates are powerful because one response can update multiple DOM regions. The runtime therefore treats invocation payloads carefully: scripts are stripped from the payload fragment before the OOB swap is applied, and invocation wrappers are removed after processing.

Text
OOB response
-> invocation target resolved
-> payload fragment extracted
-> script tags removed
-> target updated
-> invocation node removed

18. Allowed Targets and Runtime Limits

Heimdall's runtime exposes a switch for out-of-band processing. OOB invocation processing is enabled by default; the switch is useful when an application wants to ignore invocation directives and keep interaction responses limited to the primary target.

Text
// Default:
Heimdall.config.oobEnabled = true

// Optional stricter mode:
Heimdall.config.oobEnabled = false

19. What Security Page Loads Should Teach

The practical lesson is that Heimdall security is not one feature. It is a combination of request authenticity, trusted server rendering, careful runtime insertion behavior, and clear boundaries around what content is considered safe to emit.

Text
Request authenticity -> antiforgery for cookie auth,
                        or an intentional alternative
Server-owned markup  -> trust boundary
Runtime insertion    -> script stripping
App architecture     -> smallest possible trust surface

20. Common Mental Model

A useful way to think about Heimdall security is that the server remains the owner of HTML truth, while the runtime is careful about how later HTML is inserted into an already-running page.

Text
Initial page HTML
-> normal server trust boundary

Later Heimdall HTML
-> runtime insertion boundary
-> additional sanitization applied

21. Why This Fits Heimdall

Heimdall stays honest about security because it does not pretend HTML-over-the-wire is magically safer than other web programming models. It uses ordinary ASP.NET Core security primitives, enables antiforgery by default, keeps trust decisions on the server, and adds runtime protections specifically where dynamic HTML insertion needs them.

Text
ASP.NET Core security primitives
+ explicit trust boundaries
+ runtime insertion guardrails
-> secure HTML-first interaction model

22. Antiforgery Policy and Setup Matrix

Heimdall content actions require antiforgery by default. The default-enabled setup needs AddAntiforgery and UseAntiforgery. A native RequireAntiforgeryToken(false) override is narrow: declaring-type metadata applies to its actions, method metadata is nearest and wins, and all other protected actions plus Bifrost still need the antiforgery services and middleware. Global opt-out is different: when EnableAntiforgery is false, Heimdall itself requires neither AddAntiforgery nor UseAntiforgery. Keep them if non-Heimdall endpoints still use ASP.NET Core antiforgery.

C#
using Microsoft.AspNetCore.Antiforgery;

// DEFAULT-ENABLED SETUP
builder.Services.AddAntiforgery();
builder.Services.AddHeimdall();

var protectedApp = builder.Build();
protectedApp.UseAntiforgery();
protectedApp.UseHeimdall();

// Narrow exception; the rest of Heimdall stays protected.
[RequireAntiforgeryToken(false)]
[ContentInvocation("public.preview")]
public static IHtmlContent Preview(PreviewRequest request)
    => PreviewPanel.Render(request);

// ALTERNATIVE: GLOBAL OPT-OUT
var noCsrfBuilder = WebApplication.CreateBuilder(args);
noCsrfBuilder.Services.AddHeimdall(options =>
    options.EnableAntiforgery = false);

var noCsrfApp = noCsrfBuilder.Build();
noCsrfApp.UseHeimdall();
// No AddAntiforgery() or UseAntiforgery()
// is required by Heimdall in this mode.

// Set before the first action or SSE subscription.
Heimdall.config.antiforgery = false;

23. Bifrost Antiforgery

The global server setting also controls the Bifrost subscribe-token endpoint; RequireAntiforgeryToken metadata on a content action does not. Keep server and browser settings aligned. Server enabled plus browser disabled makes protected requests fail. Server disabled plus browser enabled makes the client request a CSRF token the app no longer needs and can fail when antiforgery services are absent. Topic authentication, authorization, and signed topic-bound subscribe tokens remain separate checks, so disabling antiforgery never makes a topic public by itself.

Text
EnableAntiforgery = true
-> content actions validate a token unless overridden
-> Bifrost token minting validates a token
-> Heimdall.config.antiforgery stays true

EnableAntiforgery = false
-> both token checks are skipped
-> Heimdall.config.antiforgery must be false
-> authentication and topic authorization still apply

24. Cross-Origin Frontend Policy

A frontend on another scheme, host, subdomain, or port is a different origin. Use an explicit allowlist and include the headers Heimdall actually sends. Authorization and client-info headers make browser preflight normal, so test OPTIONS as part of deployment.

JavaScript
const string FrontendPolicy = "HeimdallFrontend";

builder.Services.AddCors(options =>
{
    options.AddPolicy(FrontendPolicy, policy => policy
        .WithOrigins("https://app.example.com")
        .WithMethods("GET", "POST")
        .WithHeaders(
            "Content-Type",
            "Authorization",
            "X-Heimdall-Content-Action",
            // Include only while antiforgery is enabled.
            "RequestVerificationToken",
            "X-Heimdall-Client-Info"));
});

var app = builder.Build();
app.UseRouting();
app.UseCors(FrontendPolicy);
app.UseAuthentication();
app.UseAuthorization();
// Required only while Heimdall antiforgery is enabled.
app.UseAntiforgery();
app.UseHeimdall();

25. Cross-Origin Authentication

Bearer authentication fits the current cross-origin runtime: resolve Authorization with requestHeaders and allow that header in CORS. The runtime currently uses credentials: same-origin, so cross-origin cookies are not sent. Do not add AllowCredentials and claim cookie auth works unless the client credential mode, cookie SameSite/Secure policy, trusted origins, and CSRF design all support it.

Text
Heimdall.config.requestHeaders = async context => ({
  Authorization: `Bearer ${await auth.getToken({
    signal: context.signal
  })}`
});

// Cross-origin cookie auth is not enabled by this alone:
policy.AllowCredentials();

26. Header and Preflight Checklist

Use the smallest policy that covers the enabled feature set. A browser reports most CORS failures as a generic network error, so inspect the OPTIONS exchange and server logs before debugging Heimdall action binding.

Text
X-Heimdall-Content-Action  action routing
RequestVerificationToken  antiforgery when enabled
X-Heimdall-Client-Info     optional browser context
Authorization             application opt-in header
Content-Type              JSON or multipart request

Troubleshoot in order:
1. exact Origin is allowed
2. OPTIONS returns allowed method and headers
3. middleware order is CORS, auth, authorization,
   antiforgery when enabled, then Heimdall
4. JWT audience/issuer or antiforgery token is valid
5. browser request reaches the content endpoint

Next Steps

With the core security model in place, the main conceptual surface of Heimdall is complete. From here, the most useful follow-up is usually tightening examples, polishing reference links between pages, and refining real application patterns.