While reviewing an ASP.NET Core Razor page application that needed to share server-side configuration with client-side JavaScript, I noticed the following approach to inject a JSON object:
<script>
var featureFlags= @Html.Raw(Model.FeatureFlagsJson);
</script>
It works — until it doesn't. This post walks through the right way to do it, why the naive approach can blow up in your face, and what the production-safe pattern looks like.
Remark: There was a mistake in the original version of this post. I updated it with a fixed version of the code
Why the naive approach is dangerous
Directly interpolating server-side values into a <script> block creates an XSS (Cross-Site Scripting) vector. If any value in your config object contains characters like </script>, ", or ', the browser can interpret that as the end of your script tag — or worse, execute attacker-controlled code.
Consider this innocent-looking config value:
public string FeatureFlags{ get; set; } = "My App </script><script>alert('pwned')";
Inlined naively, that produces:
<script>
var featureFlags= { appName: "My App </script><script>alert('pwned')" };
</script>
The browser sees the </script> as closing your block, and the injected script runs.
A simple and clean solution: <script type="application/json">
I flagged the issue in the PR and asked the developer to find a solution. She came up with a solution I wasn’t aware that it existed: using a <script> tag with a non-executable MIME type as a data container.
@page
@model IndexModel
<script type="application/json" id="featureflags-config">
@Html.Raw(Model.FeatureFlagsJson)
</script>
Because the browser only executes <script> tags with a JavaScript MIME type (or no type at all), a type="application/json" block is treated as inert data — the content is never parsed as code. That means a </script> sequence inside your JSON cannot break out of the block or execute anything. The browser simply ignores it as a script.
You then read it in JavaScript with a single JSON.parse:
const configEl = document.getElementById('featureflags-config');
const config = JSON.parse(configEl.textContent);
if (config.featureFlags.newDashboard) {
initNewDashboard();
}
What on the C# side?
On the C# side, we need to serialize our JSON using the default options:
var options = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.Default,
WriteIndented = false
};
ClientConfigJson = JsonSerializer.Serialize(config, options);
You still need Html.Raw in the Razor view: Razor's default HTML encoding turns " into " server-side, before the browser ever sees the page. JSON.parse receives the literal string " and fails immediately.
<script type="application/json" id="featureflags-config">@Model.ClientConfigJson</script>
What about a <meta> tag or a separate endpoint?
Two common alternatives worth knowing about:
<meta> tags work well for a single scalar value but are awkward for a structured object and require manual parsing in JavaScript. Not ideal for a config object.
A dedicated /config JSON endpoint is a clean approach for large or sensitive configs, at the cost of an extra HTTP round-trip before your app can initialize. If your config is large or access-controlled, this is worth considering. For lightweight, non-sensitive config that's available at page render time, the inline approach above is simpler and faster.
Checklist
- Never interpolate raw C# strings directly into
<script>blocks - If using an executable
<script>block: serialize withJavaScriptEncoder.Defaultand useHtml.Raw - If using
<script type="application/json">: read viatextContent+JSON.parse, notinnerHTML - Keep secrets (API keys, connection strings) off the client config entirely — this is for UI configuration, not credentials If using the
application/jsonapproach, make sure your JS runs after the DOM element exists (deferorDOMContentLoaded)
The pattern is simple once you have it in place, and it closes a class of injection bugs that are easy to introduce and nasty to debug.