There are two things we may want to customise when we use Flask-Security-Too: pages and emails. For example, the login page or the account confirmation email. Let's take a look at how to do it.

This is the final article in this series of blog posts, where I show you how to work with Flask-Security-Too:

  1. User authentication in Flask with Flask-Security-Too
  2. User email confirmations with Flask-Security-Too
  3. Customising templates and emails of Flask-Security-Too (this article)

All the page and email templates in Flask-Security-Too are written using HTML and Jinja, and you can find them all inside your virtual environment's folder, and then inside lib/python3/site-packages/flask_security/templates/security.

Screenshot showing the author's virtual environment folder open with the flask_security package shown.

In this article, we will customise three templates:

  • login_user.html
  • _macros.html
  • register_user.html

Doing this will help you learn how to customise any template, so you can do the same for others that you wish to modify.

Customising the login template

Begin by opening the login_user.html default template and copying its contents.

Then, inside your Flask app's templates folder, create a security folder. Inside there, create login_user.html, and paste the contents you copied from the default template.

Just by doing this, your Flask app will use your template instead of the default. For now, they have the same content! Let's look at how to change the content.

When I wrote this article, this was the content of login_user.html:

{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain('Login') }}</h1>
  <form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
    {{ login_user_form.hidden_tag() }}
    {{ render_form_errors(login_user_form) }}
    {% if "email" in identity_attributes %}
      {{ render_field_with_errors(login_user_form.email) }}
    {% endif %}
    {% if login_user_form.username and "username" in identity_attributes %}
      {% if "email" in identity_attributes %}
        <h3>{{ _fsdomain("or") }}</h3>
      {% endif %}
      {{ render_field_with_errors(login_user_form.username) }}
    {% endif %}
    <div class="fs-gap">
      {{ render_field_with_errors(login_user_form.password) }}</div>
    {{ render_field_with_errors(login_user_form.remember) }}
    {{ render_field_errors(login_user_form.csrf_token) }}
    {{ render_field(login_user_form.submit) }}
  </form>
  {% if security.webauthn %}
    <hr class="fs-gap">
    <h2>{{ _fsdomain("Use WebAuthn to Sign In") }}</h2>
    <div>
      <form method="GET" id="wan-signin-form" name="wan_signin_form">
        <input id="wan_signin" name="wan_signin" type="submit" value="{{ _fsdomain('Sign in with WebAuthn') }}"
          formaction="{{ url_for_security("wan_signin") }}">
      </form>
    </div>
  {% endif %}
{% include "security/_menu.html" %}
{% endblock %}

As you can see, there isn't much actual HTML in here. Mostly it's importing other files and using some pre-defined macros. If we want to change the style of the page, we would need to replace everything:

  • The security/_messages.html file.
  • The macros for rendering fields.
  • The security/_menu.html file.

Lately, I've been using TailwindCSS for all my CSS, so if you're using that, here are two templates you can use.

{% block content %}
<div class="flex flex-col min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
  <div class="w-full max-w-md space-y-8">
    {% set messages = get_flashed_messages() %}
    {% if messages %}
      <div class="w-full my-6">
        {% for category, message in get_flashed_messages(with_categories=true) %}
          <p>{{ message }}</p>
        {% endfor %}
      </div>
    {% endif %}
    <div>
      <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">Sign in to your account</h2>
      <p class="mt-2 text-center text-sm text-gray-600">
        Or
        <a href="{{ url_for_security('register') }}" class="font-medium text-indigo-600 hover:text-indigo-500">sign up instead?</a>
      </p>
    </div>
    <form action="{{ url_for_security('login') }}" method="POST" class="mt-8 space-y-6">
      {{ login_user_form.hidden_tag() }}
      {{ render_signup_login_field_w_errors(login_user_form.email, placeholder="Email", autocomplete="email") }}
      {{ render_signup_login_field_w_errors(login_user_form.password, placeholder="Password", autocomplete="current-password") }}
      {{ render_checkbox_with_errors(login_user_form.remember) }}
    
      <div>
        <button
          type="submit"
          class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
        >
          Sign in
        </button>
      </div>
    </form>
  </div>
</div>
{% endblock %}

And in security/_macros.html:

{% macro render_checkbox_with_errors(field) %}
  <div class="relative flex items-start">
    <div class="flex items-center h-5">
    {{ field(class_="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded", **kwargs)|safe }}
    </div>
    <div class="ml-3 text-sm">
    {{ field.label(class_="text-gray-700") }}</div>
    {% if field.errors %}
      <ul>
      {% for error in field.errors %}
        <li>{{ error }}</li>
      {% endfor %}
      </ul>
    {% endif %}
  </div>
{% endmacro %}

{% macro render_signup_login_field_w_errors(field, class_) %}
  <p class="{{ class_ }}">
    {{ field.label(class_="sr-only") }} {{ field(class_="relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm", **kwargs)|safe }}
    {% if field.errors %}
      <ul>
      {% for error in field.errors %}
        <li>{{ error }}</li>
      {% endfor %}
      </ul>
    {% endif %}
  </p>
{% endmacro %}

Then in security/register_user.html:

{% extends "security/base.html" %}
{% from "security/_macros.html" import render_signup_login_field_w_errors %}

{% block content %}
<div class="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
  <div class="w-full max-w-md space-y-8">
    <div>
      <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">Create your account</h2>
      <p class="mt-2 text-center text-sm text-gray-600">
        Or
        <a href="{{ url_for_security('login') }}" class="font-medium text-indigo-600 hover:text-indigo-500">log in instead?</a>
      </p>
    </div>
    <form action="{{ url_for_security('register') }}" method="POST" class="mt-8 space-y-6">
      {{ register_user_form.hidden_tag() }}
      {{ render_signup_login_field_w_errors(register_user_form.full_name, placeholder="Full name", autocomplete="name") }}
      {{ render_signup_login_field_w_errors(register_user_form.email, placeholder="Email", autocomplete="email") }}
      {% if security.username_enable %}
      {{ render_signup_login_field_w_errors(register_user_form.username, placeholder="Full name", autocomplete="name") }}
      {% endif %}
      {{ render_signup_login_field_w_errors(register_user_form.password, placeholder="Password", autocomplete="current-password") }}
      {% if register_user_form.password_confirm %}
      {{ render_signup_login_field_w_errors(register_user_form.password_confirm, placeholder="Confirm password") }}
      {% endif %}

    
      <div>
        <button
          type="submit"
          class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
        >
          Create your account
        </button>
        <p class="mt-3 text-center text-sm text-slate-700">By creating an account, you agree to our <a class="font-semibold" href="{{ url_for('resource_page', page_slug='terms-of-service') }}">Terms of Service</a> and <a class="font-semibold" href="{{ url_for('resource_page', page_slug='privacy-policy') }}">Privacy Policy</a>.</p>
      </div>
    </form>
  </div>
</div>
{% endblock %}

Customising templates by linking a stylesheet

If you wanted to add a CSS stylesheet instead of changing the template, that would be much easier. You'd copy and paste the original file contents, and link the CSS stylesheet like so:

{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

{% block styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css) }}">
{% endblock %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain('Login') }}</h1>
  <form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
    {{ login_user_form.hidden_tag() }}
    {{ render_form_errors(login_user_form) }}
    {% if "email" in identity_attributes %}
      {{ render_field_with_errors(login_user_form.email) }}
    {% endif %}
    {% if login_user_form.username and "username" in identity_attributes %}
      {% if "email" in identity_attributes %}
        <h3>{{ _fsdomain("or") }}</h3>
      {% endif %}
      {{ render_field_with_errors(login_user_form.username) }}
    {% endif %}
    <div class="fs-gap">
      {{ render_field_with_errors(login_user_form.password) }}</div>
    {{ render_field_with_errors(login_user_form.remember) }}
    {{ render_field_errors(login_user_form.csrf_token) }}
    {{ render_field(login_user_form.submit) }}
  </form>
  {% if security.webauthn %}
    <hr class="fs-gap">
    <h2>{{ _fsdomain("Use WebAuthn to Sign In") }}</h2>
    <div>
      <form method="GET" id="wan-signin-form" name="wan_signin_form">
        <input id="wan_signin" name="wan_signin" type="submit" value="{{ _fsdomain('Sign in with WebAuthn') }}"
          formaction="{{ url_for_security("wan_signin") }}">
      </form>
    </div>
  {% endif %}
{% include "security/_menu.html" %}
{% endblock %}

The only thing that was added to the original content in the code above is this under the imports. This assumes that the CSS file name is under static/css/auth.css:

{% block styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css) }}">
{% endblock %}

Remember that this original template extends the security/base.html template, so you can look at that one to see what different block elements are available.

Customising the confirmation email template

Next up, I like changing the look of the email confirmation template. The default template is basic, with just text. You can find it inside the templates/email/welcome.html file inside the flask_security folder in your virtual environment.

You'll also be able to find welcome.txt, which is a text representation of the email content. This will be used in those email clients that don't support HTML rendering (or have it disabled).

Create a templates/email/welcome.html file.

This is what my template looks like:

{# This template receives the following context:
  confirmation_link - the link that should be fetched (GET) to confirm
  confirmation_token - this token is part of confirmation link - but can be used to
    construct arbitrary URLs for redirecting.
  user - the entire user model object
  security - the Flask-Security configuration
#}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Confirm your email</title>


<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
  body {
    padding: 0 !important;
  }
  h1 {
    font-weight: 800 !important; margin: 20px 0 5px !important;
  }
  h2 {
    font-weight: 800 !important; margin: 20px 0 5px !important;
  }
  h3 {
    font-weight: 800 !important; margin: 20px 0 5px !important;
  }
  h4 {
    font-weight: 800 !important; margin: 20px 0 5px !important;
  }
  h1 {
    font-size: 22px !important;
  }
  h2 {
    font-size: 18px !important;
  }
  h3 {
    font-size: 16px !important;
  }
  .container {
    padding: 0 !important; width: 100% !important;
  }
  .content {
    padding: 0 !important;
  }
  .content-wrap {
    padding: 10px !important;
  }
  .invoice {
    width: 100% !important;
  }
}
</style>
</head>

<body itemscope itemtype="http://schema.org/EmailMessage" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">

<table class="body-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
        <td class="container" width="600" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
            <div class="content" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;">
                <table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;" valign="top">
                            <meta itemprop="name" content="Confirm Email" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" /><table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
                                        Hey, {{user.username}}! {{ _fsdomain('Please confirm your email through the link below:') }}
                                    </td>
                                </tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block" itemprop="handler" itemscope itemtype="http://schema.org/HttpActionHandler" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
                                        <a href="{{ confirmation_link }}" class="btn-primary" itemprop="url" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize; background-color: #4F46E5; margin: 0; border-color: #4F46E5; border-style: solid; border-width: 6px 20px;">{{ _fsdomain('Confirm my account') }}</a>
                                    </td>
                                </tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
                                        If clicking the button doesn't work, please copy this link and paste it into your browser's address bar:
                                    </td>
                                </tr><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px; max-width: 20em;" valign="top">
                                        <a href="{{ confirmation_link }}">{{ confirmation_link }}</a>
                                    </td>
                                </tr>
                                <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
                                        &mdash; Jose and the Teclado team
                                    </td>
                                </tr></table></td>
                    </tr></table><div class="footer" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;">
                    <table width="100%" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"><td class="aligncenter content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">Follow <a href="http://twitter.com/tecladocode" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; color: #999; text-decoration: underline; margin: 0;">@tecladocode</a> on Twitter.</td>
                        </tr></table></div></div>
        </td>
        <td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
    </tr></table></body>
</html>

As you can see, it's long and rather confusing! That's because writing emails using HTML is really difficult. There are so many email clients, and they don't all support the same CSS.

My email is taken from Mailgun's official templates that you can see here, and I've adapted it to my needs. Mailgun has a nice writeup on HTML emails that you can read here.

Alright, that's everything for now! Thank you very much for reading, and I hope this series of articles has been useful. If you'd like to learn more about web development using Flask, consider enrolling in our Web Developer Bootcamp with Flask and Python! It's a complete video course that covers building multiple web apps and deploying them, all using Flask.

See you next time!