How to Set Up Email Verification in a Flask App

How to Set Up Email Verification in a Flask App

Email verification is a crucial aspect of creating a new user account or signing up for a service. It helps confirm that the email address provided is valid and belongs to the intended user.

In this article, we will explore the process of handling email verification in Flask. The topic will include setting up a route to handle the email verification process and storing the verification status in a database.

By the end of this article, you will have a thorough understanding of how to implement email verification in your Flask application.

Before getting started, make sure you have a good understanding of basic user authentication in Flask. You can go through this tutorial to learn more.

Project Demo

Here’s what you're going to build in this tutorial:

The link to the GitHub repository is available at the end of the tutorial. Feel free to check it out whenever you're stuck.

Flask Basic User Authentication

To begin, you will use a Flask boilerplate that includes basic user authentication. You can get the code from this repository. After creating and activating a virtual environment, run the following command to install all the dependencies:

$ pip install -r requirements.txt

Create a file named .env in the root directory and add the following content there:

export SECRET_KEY=fdkjshfhjsdfdskfdsfdcbsjdkfdsdf
export DEBUG=True
export APP_SETTINGS=config.DevelopmentConfig
export DATABASE_URL=sqlite:///db.sqlite
export FLASK_APP=src
export FLASK_DEBUG=1

Run the following command to export all the environment variables from the .env file:

source .env

Run the following commands to set up the database:

flask db init
flask db upgrade

Run the following command to run the Flask server:

python manage.py run

Once the app is running, go to http://localhost:5000/register to register a new user. You will notice that after completing the registration, the app will automatically log you in and redirect you to the main page.

Before proceeding, I'd recommend exploring the app and then reviewing the code, particularly the accounts blueprint. This will give you a better understanding of how user authentication is implemented.

How to Modify the Current Implementation

In this section, you will modify the existing implementation of user authentication in our Flask app.

How to update the User model

First of all, you'll need to add two new fields – is_confirmed and confirmed_on in the User model of your app.

Open the src/accounts/models.py file and update the User class with the following:

class User(UserMixin, db.Model):

    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String, unique=True, nullable=False)
    password = db.Column(db.String, nullable=False)
    created_on = db.Column(db.DateTime, nullable=False)
    is_admin = db.Column(db.Boolean, nullable=False, default=False)
    is_confirmed = db.Column(db.Boolean, nullable=False, default=False)
    confirmed_on = db.Column(db.DateTime, nullable=True)

    def __init__(
        self, email, password, is_admin=False, is_confirmed=False, confirmed_on=None
    ):
        self.email = email
        self.password = bcrypt.generate_password_hash(password)
        self.created_on = datetime.now()
        self.is_admin = is_admin
        self.is_confirmed = is_confirmed
        self.confirmed_on = confirmed_on

    def __repr__(self):
        return f"<email {self.email}>"

The is_confirmed is a boolean field to indicate whether the user's email address has been confirmed, set as not nullable and defaulting to False. The confirmed_on is a datetime field for the time when the user's email was confirmed, set as nullable.

To migrate and apply these changes in the database, run the following commands:

flask db migrate
flask db upgrade

How to update the create_admin function

Next, in the manage.py file, update the create_admin command to take the new database fields into account:

@cli.command("create_admin")
def create_admin():
    """Creates the admin user."""
    email = input("Enter email address: ")
    password = getpass.getpass("Enter password: ")
    confirm_password = getpass.getpass("Enter password again: ")
    if password != confirm_password:
        print("Passwords don't match")
    else:
        try:
            user = User(
                email=email,
                password=password,
                is_admin=True,
                is_confirmed=True,
                confirmed_on=datetime.now(),
            )
            db.session.add(user)
            db.session.commit()
            print(f"Admin with email {email} created successfully!")
        except Exception:
            print("Couldn't create admin user.")

Notice that the is_confirmed field is set to True in this case, because you don't want the admin to verify their account.

How to add a new decorator to check if the user is logged out

If you notice the register() and login() function in src/accounts/views.py, you'll find the below code in both of them:

if current_user.is_authenticated:
    flash("You are already registered.", "info")
    return redirect(url_for("core.home"))

The code checks whether a user is already logged in. If the user is authenticated, a message is displayed using the flash function and the user is redirected to the home page using the redirect function from Flask.

If the user is not authenticated, they will be allowed to continue with the process they were trying to complete. This essentially means you require the user to be logged out to continue the flow.

Instead of repeating the code at two places, you can create a decorator to check if the user is logged out.

Create a new utils folder in the src folder, and a decorators.py file in the utils folder with the following content:

from functools import wraps

from flask import flash, redirect, url_for
from flask_login import current_user


def logout_required(func):
    @wraps(func)
    def decorated_function(*args, **kwargs):
        if current_user.is_authenticated:
            flash("You are already authenticated.", "info")
            return redirect(url_for("core.home"))
        return func(*args, **kwargs)

    return decorated_function

The above code defines a decorator called logout_required which is used to wrap routes in a Flask app.

If the user is authenticated, a message is displayed using the flash function and the user is redirected to the home page using the redirect function from Flask. If the user is not authenticated, they will be allowed to continue and the decorated route function will be executed.

Now, you can use this decorator in the register() and login() function as below:

from src.utils.decorators import logout_required


@accounts_bp.route("/register", methods=["GET", "POST"])
@logout_required
def register():
    ...


@accounts_bp.route("/login", methods=["GET", "POST"])
@logout_required
def login():
    ...

How to Add Email Verification to Your Flask App

In this section, you will learn how to add email verification to your Flask app.

How to generate the confirmation token

The email confirmation should contain a unique URL that the user can click to confirm their account. The URL should be in the following format: http://localhost:5000/confirm/<id>.

The <id> portion of the URL is a unique identifier that is generated using the user's email address and a timestamp. We can use the itsdangerous package to encode this information in the <id>. When the user clicks the link, the app can decode the <id> to retrieve the user's email address and verify their account.

To provide an additional layer of security to the token, the itsdangerours package requires a password salt. You can set an environment variable for the same in the .env file:

other env vars...
export SECURITY_PASSWORD_SALT=fkslkfsdlkfnsdfnsfd

Run the following command to export the environment variable from the .env file:

source .env

Add the SECURITY_PASSWORD_SALT to your app’s config (Config) in the config.py file:

class Config:
    ...other configs
    SECURITY_PASSWORD_SALT = config("SECURITY_PASSWORD_SALT", default="very-important")

Create a src/accounts/token.py file and add the following code:

from itsdangerous import URLSafeTimedSerializer

from src import app


def generate_token(email):
    serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
    return serializer.dumps(email, salt=app.config["SECURITY_PASSWORD_SALT"])


def confirm_token(token, expiration=3600):
    serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
    try:
        email = serializer.loads(
            token, salt=app.config["SECURITY_PASSWORD_SALT"], max_age=expiration
        )
        return email
    except Exception:
        return False

This code defines two functions for generating and confirming tokens for email verification in a Flask app:

The generate_token function takes an email address as an argument and returns a token that is generated using the URLSafeTimedSerializer class from the itsdangerous package.

The URLSafeTimedSerializer class is initialized with the app's secret key, which is stored in the SECRET_KEY configuration variable. The dumps method of the URLSafeTimedSerializer instance is called with the email address and a password salt as arguments. As mentioned earlier, the password salt is stored in the SECURITY_PASSWORD_SALT configuration variable.

The confirm_token function takes a token and an optional expiration time as arguments and returns the email address that was used to generate the token.

The URLSafeTimedSerializer instance is initialized with the app's secret key and the loads method is called with the token, password salt, and expiration time as arguments.

If the token is valid and has not expired, the email address is returned. If the token is invalid or has expired, an exception is raised and caught by the except block, causing the function to return False.

How to update the register() function

When the user registers, you need to generate a token using the email address of the user.

from src.accounts.token import confirm_token, generate_token


@accounts_bp.route("/register", methods=["GET", "POST"])
@logout_required
def register():
    form = RegisterForm(request.form)
    if form.validate_on_submit():
        user = User(email=form.email.data, password=form.password.data)
        db.session.add(user)
        db.session.commit()

        token = generate_token(user.email)

        ...

The token variable will be used while sending an email to the user with the token included in the email's URL.

How to handle email confirmation

To confirm the email, create a new view function in the src/accounts/views.py file:

@accounts_bp.route("/confirm/<token>")
@login_required
def confirm_email(token):
    if current_user.is_confirmed:
        flash("Account already confirmed.", "success")
        return redirect(url_for("core.home"))
    email = confirm_token(token)
    user = User.query.filter_by(email=current_user.email).first_or_404()
    if user.email == email:
        user.is_confirmed = True
        user.confirmed_on = datetime.now()
        db.session.add(user)
        db.session.commit()
        flash("You have confirmed your account. Thanks!", "success")
    else:
        flash("The confirmation link is invalid or has expired.", "danger")
    return redirect(url_for("core.home"))

The above code defines a route for the confirm_email view function in a Flask app. The route is decorated with the @login_required decorator, which requires the user to be logged in to access the view. The route takes a token argument, which is included in the URL of the route.

The view function first checks if the user's account is already confirmed. If the account is already confirmed, a message is displayed using the flash function from the Flask-Babel library and the user is redirected to the home page using the redirect function from Flask.

If the account is not confirmed, the confirm_token function is called with the token as an argument to confirm the token and retrieve the email address that was used to generate the token. If the token is invalid or has expired, the confirm_token function returns False, and a message is displayed indicating that the confirmation link is invalid or has expired.

If the token is valid, the user's account is confirmed by setting the is_confirmed field to True and the confirmed_on field to the current time. The changes are then committed to the database. A message is displayed indicating that the account has been confirmed, and the user is redirected to the home page.

How to send a confirmation email

Let's first create a basic email template that will be used while sending the email. Create a templates/accounts/confirm_email.html file and add the following code:

<p>
  Welcome! Thanks for signing up. Please follow this link to activate your
  account:
</p>
<p><a href="{{ confirm_url }}">{{ confirm_url }}</a></p>
<br />
<p>Cheers!</p>

The confirm_url placeholder is used to insert a URL into the email message. When the template is rendered, the confirm_url placeholder is replaced with the actual URL that the user should visit to confirm their account.

How to set up Flask-Mail

Next, you'll need a library called Flask-Mail to send out emails using Flask.

Install the library using the pip command:

pip install Flask-Mail

Initialize the Flask-Mail library inside the src/__init__.py file as:

...other imports...
from flask_mail import Mail

...other initializations...
mail = Mail(app)
...

You can set environment variables for your email and password that will be used to send emails in the .env file:

export EMAIL_USER=your-email
export EMAIL_PASSWORD=your-password

Run the following command to export the environment variables from the .env file:

source .env

Update the Config class in the config.py file:

class Config(object):
    ...other configs

    # Mail Settings
    MAIL_DEFAULT_SENDER = "noreply@flask.com"
    MAIL_SERVER = "smtp.gmail.com"
    MAIL_PORT = 465
    MAIL_USE_TLS = False
    MAIL_USE_SSL = True
    MAIL_DEBUG = False
    MAIL_USERNAME = config("EMAIL_USER")
    MAIL_PASSWORD = config("EMAIL_PASSWORD")

Note: If your Gmail account has 2-step authentication, Google will block the attempt. Use an app password to sign in.

How to create a function to send an email

Next, create a email.py file in the src/utils folder and add the following code:

from flask_mail import Message

from src import app, mail


def send_email(to, subject, template):
    msg = Message(
        subject,
        recipients=[to],
        html=template,
        sender=app.config["MAIL_DEFAULT_SENDER"],
    )
    mail.send(msg)

The function takes three arguments: the recipient's email address (to), the subject of the email (subject), and the body of the email (template).

The Message class from the Flask-Mail library is used to create a new email message with the specified subject and recipient. The html argument is used to set the body of the email to the provided template, which is expected to be an HTML string. The sender argument is used to specify the default sender for the email, which is stored in the MAIL_DEFAULT_SENDER configuration variable.

The send method of the mail object is then called with the message object as an argument to send the email.

How to send the email (finally)

Let's finally send the confirmation email from the register() function:

from src.utils.email import send_email


@accounts_bp.route("/register", methods=["GET", "POST"])
@logout_required
def register():
    form = RegisterForm(request.form)
    if form.validate_on_submit():
        user = User(email=form.email.data, password=form.password.data)
        db.session.add(user)
        db.session.commit()
        token = generate_token(user.email)
        confirm_url = url_for("accounts.confirm_email", token=token, _external=True)
        html = render_template("accounts/confirm_email.html", confirm_url=confirm_url)
        subject = "Please confirm your email"
        send_email(user.email, subject, html)

        login_user(user)

        flash("A confirmation email has been sent via email.", "success")
        return redirect(url_for("accounts.inactive"))

    return render_template("accounts/register.html", form=form)

The url_for function is then called to generate a URL for the confirm_email route with the token as an argument. The _external argument is set to True to generate an absolute URL with the full domain name. The render_template function is called to render an HTML template for the email message, using the confirm_url as a placeholder in the template.

The send_email function is then called to send the email with the rendered template as the body, using the user's email address as the recipient and a subject of "Please confirm your email".

Finally, the user is logged in using the login_user function from the Flask-Login library and a message is displayed indicating that a confirmation email has been sent. The user is then redirected to the inactive view. You'll create it later.

A sample email looks like this:

Screenshot-2023-01-05-234354

How to Handle Inactive Accounts

Whenever you create a new account, you're redirected to a view called inactive where you're asked to confirm your account.

Let's create a view function in the src/accounts/views.py file:

@accounts_bp.route("/inactive")
@login_required
def inactive():
    if current_user.is_confirmed:
        return redirect(url_for("core.home"))
    return render_template("accounts/inactive.html")

The view function checks if the user's account is confirmed. If the account is confirmed, the user is redirected to the home page using the redirect function from Flask. If the account is not confirmed, the inactive.html template is rendered using the render_template function from Flask.

Let's create the inactive.html file in templates/accounts folder:

{% extends "_base.html" %} {% block content %}

<div class="text-center">
  <h1>Welcome!</h1>
  <br />
  <p>
    You have not confirmed your account. Please check your inbox (and your spam
    folder) - you should have received an email with a confirmation link.
  </p>
  <p>
    Didn't get the email?
    <a href="{{ url_for('accounts.resend_confirmation') }}">Resend</a>.
  </p>
</div>

{% endblock %}

The template includes a link to the resend_confirmation route.

How to resend the email

Consider a case where the user was not able to confirm the account before the expiration time of the token. As a user, you'll want to have the option to resend the email, right?

Create a new view function in the src/accounts/views.py:

@accounts_bp.route("/resend")
@login_required
def resend_confirmation():
    if current_user.is_confirmed:
        flash("Your account has already been confirmed.", "success")
        return redirect(url_for("core.home"))
    token = generate_token(current_user.email)
    confirm_url = url_for("accounts.confirm_email", token=token, _external=True)
    html = render_template("accounts/confirm_email.html", confirm_url=confirm_url)
    subject = "Please confirm your email"
    send_email(current_user.email, subject, html)
    flash("A new confirmation email has been sent.", "success")
    return redirect(url_for("accounts.inactive"))

This code defines a route for the resend_confirmation view function in a Flask app. The route is decorated with the login_required decorator, which requires the user to be logged in to access the view.

The view function first checks if the user's account is already confirmed. If the account is confirmed, a message is displayed indicating that the account has already been confirmed and the user is redirected to the home page.

If the account is not confirmed, a token is generated as earlier. The url_for function is then called to generate a URL for the confirm_email route with the token as an argument. The send_email function is then called to send the email with the rendered template as the body, using the user's email address as the recipient and a subject of "Please confirm your email". A message is displayed indicating that a new confirmation email has been sent, and the user is redirected to the inactive view.

How to add middleware for routes

Now that you have the email verification mechanism ready, you want your routes in the core package to be accessed by only users with a confirmed account. To do that, you can add a decorator on those routes.

Create a new decorator in the src/utils/decorators.py file:

def check_is_confirmed(func):
    @wraps(func)
    def decorated_function(*args, **kwargs):
        if current_user.is_confirmed is False:
            flash("Please confirm your account!", "warning")
            return redirect(url_for("accounts.inactive"))
        return func(*args, **kwargs)

    return decorated_function

This code defines a decorator called check_is_confirmed. The decorator takes a function as an argument and returns a decorated function.

The decorator works by checking if the user's account is confirmed. If the account is not confirmed, a message is displayed warning the user to confirm their account, and the user is redirected to the inactive view. If the account is confirmed, the decorated function is called as usual.

Now, you can use this decorator in the home view function in src/core/views.py file:

from src.utils.decorators import check_is_confirmed


@core_bp.route("/")
@login_required
@check_is_confirmed
def home():
    return render_template("core/index.html")

This is how it looks when you login and your account is not verified:

Screenshot-2023-01-05-234723

How to Add New Test Cases

Now that you have added the main functionality, it's time to update the test suite.

How to modify the setUp() method in base_test.py

Replace the setUp() method with the following code:

def setUp(self):
    db.create_all()
    unconfirmed_user = User(email="unconfirmeduser@gmail.com",
                                password="unconfirmeduser")
    db.session.add(unconfirmed_user)
    confirmed_user = User(email="confirmeduser@gmail.com",
                              password="confirmeduser", is_confirmed=True)
    db.session.add(confirmed_user)
    db.session.commit()

The method now creates two users – a user with a confirmed account and another user without a confirmed account.

Note that you'll need to replace the usage of the old email address and password with the unconfirmed user's email address and password in all the test files.

How to add new test cases in test_routes.py

Since unauthenticated users cannot access the home page, let's add a test case in the TestLoggingInOut class to see if the user is redirected to the login page:

def test_home_route_requires_login(self):
    self.client.get("/logout", follow_redirects=True)
    self.client.get('/', follow_redirects=True)
    self.assertTemplateUsed('accounts/login.html')

Create a new TestEmailConfirmationToken class in the same file:

class TestEmailConfirmationToken(BaseTestCase):
    def test_confirm_token_route_requires_login(self):
        # Ensure confirm/<token> route requires logged in user.
        self.client.get("/logout", follow_redirects=True)
        self.client.get('/confirm/some-unique-id', follow_redirects=True)
        self.assertTemplateUsed('accounts/login.html')

    def test_confirm_token_route_valid_token(self):
        # Ensure user can confirm account with valid token.
        with self.client:
            self.client.get("/logout", follow_redirects=True)
            self.client.post('/login', data=dict(
                email='unconfirmeduser@gmail.com', password='unconfirmeduser'
            ), follow_redirects=True)
            token = generate_token('unconfirmeduser@gmail.com')
            response = self.client.get(
                '/confirm/'+token, follow_redirects=True)
            self.assertIn(
                b'You have confirmed your account. Thanks!', response.data)
            self.assertTemplateUsed('core/index.html')
            user = User.query.filter_by(
                email='unconfirmeduser@gmail.com').first_or_404()
            self.assertIsInstance(user.confirmed_on, datetime)
            self.assertTrue(user.is_confirmed)

    def test_confirm_token_route_invalid_token(self):
        # Ensure user cannot confirm account with invalid token.
        token = generate_token('test@test1.com')
        with self.client:
            self.client.get("/logout", follow_redirects=True)
            self.client.post('/login', data=dict(
                email='unconfirmeduser@gmail.com', password='unconfirmeduser'
            ), follow_redirects=True)
            response = self.client.get('/confirm/'+token,
                                       follow_redirects=True)
            self.assertIn(
                b'The confirmation link is invalid or has expired.',
                response.data
            )

    def test_confirm_token_route_expired_token(self):
        # Ensure user cannot confirm account with expired token.
        user = User(email='test@test1.com', password='test1')
        db.session.add(user)
        db.session.commit()
        token = generate_token('test@test1.com')
        self.assertFalse(confirm_token(token, -1))

The above code tests the email verification functionality of the app.

  • The test_confirm_token_route_requires_login test case tests that when a user tries to access the confirmation route when not logged in, they are redirected to the login page.

  • The test_confirm_token_route_valid_token test case tests that when a user tries to access the confirmation route with a valid token, their account is confirmed and they are redirected to the index page.

  • The test_confirm_token_route_invalid_token test case tests that when a user tries to access the confirmation route with an invalid token, an error message is displayed.

  • The test_confirm_token_route_expired_token test case tests that when a user tries to access the confirmation route with an expired token, an error message is displayed.

Wrapping up

In this tutorial, you learned how to handle email verification in your Flask app. You also wrote a few more test cases to test the new functionalities.

Here's the link to the GitHub repository. Feel free to check it out whenever you're stuck.

  • You can add a "forgot password" feature in the application.

  • You can allow users to manage their profiles.

  • You can add more test cases to test the app more thoroughly.

Thank you for reading. I hope you found this article useful. You can follow me on Twitter.

Did you find this article valuable?

Support Ashutosh Krishna by becoming a sponsor. Any amount is appreciated!