Jacob Padilla

Create a Secure Flask Login System Using Argon2 Hashing

In this article, I'm going to explain how to make a simple login system that uses Flask in the backend, Argon2 for password hashing, signed cookies for session management, and an SQLite database to store user data. If you would like to follow along or play around with the example website from this article, you can find it in this Github repo!

The end product will allow you to log in, register accounts, and have user-only pages and will look like the following:


Example Website Demo

How a Login System Works

Before diving into the example website, I want to go over some of the important pieces that make up any login system.

At its core, you need a way to store user information, such as usernames, emails, and passwords; this generally comes in the form of a database. Once you have a method of storing the user data, you need to make sure that it's not vulnerable to data breaches - the most common way of doing this is by keeping a hashed version of the password in the database.

Next, you'll need to have a method to keep track of which users are logged in. To do this, I'll be using Flask's built-in Session object, which is an easy-to-use interface for implementing a signed cookie. Finally, when a user tries to access a login-required page, we will make a Python decorator that checks the Session cookie and validates the request.

Storing Passwords

As mentioned above, unless required for some reason, it's best to hash passwords. Hashing is a one-way process, meaning that once you hash the password, if a hacker gets their hands on a database with the hashed passwords, they won't be able to easily decode them.

If hashing is a one-way process, how do we verify if a user entered the correct password for their account when trying to log in? Well, when a user tries to log in, all we need to do is use a unique identifier, such as a username or email, to find the corresponding hashed password in the user database and then compute the hash of the user-submitted password that they are trying to log in with and then see if the two hashes are the same. If they match, it means that the underlying passwords are also the same.

According to OWASP, Argon2 is the recommended hashing algorithm for storing passwords. Thus, I'll be using a Python binding of Argon2 called argon-cffi for this article.

When we hash a password with Argon2, the result is a string that has the following components:

Argon2 hash diagram

The first three parts of the string keep track of the specifications used to make the hash; on the right is the actual hash, and in the center is the “salt” of the hash, which argon2-cffi adds automatically.

Adding a salt to a password when hashing is very important as it adds another level of security to the hash. A salt is a random set of characters that you add to the password before hashing it. This makes sure that every hashed password is unique and long, which makes it harder for attackers to brute force the hashed passwords using methods such as rainbow tables.

Cookies

Now that we've discussed how to store and verify passwords when a user registers and logs in, we need to discuss how the server will actually keep track of logged-in users.


The Office Cookies Meme

Cookies are a reliable mechanism for websites to remember stateful information, such as items in a shopping cart or if a user is logged in. They are small pieces of data that, in this article, will be sent in the response part of an HTTP request and subsequently stored in the user's web browser. Then, with every request, the browser will send this cookie back to the server, allowing the server to recognize the user and maintain their logged-in state.

Flask has a handy class called "session" which is an easy-to-use interface for handling session data; It's somewhat similar to JSON Web Tokens. The flask.session object, which is built on top of another great package called ItsDangerous, has an interface that acts like a typical Python Dictionary where you can add and remove key-value pairs. The dictionary then gets hashed with a secret key that only your server knows (this process is called signing the cookie). The cookie itself then holds a string that contains the hashed data as well as the original data (<encoded_data>.<encoded_signature>).

This method works great because, while the user can see the original data, as it's stored directly in the cookie, they can't change it since they don't have the secret key that was used to hash the original data. When the server receives this signed cookie, the flask session object can verify the validity of the cookie by taking the un-hashed data from the cookie, appending the secret key to it, and then applying the same hashing algorithm and checking if it matches the hash part of the cookie.

To create a secret key, Flask recommends using the following code:

$python -c 'import secrets; print(secrets.token_hex())'

Making a Login System

Now, it's time to make a Flask user authentication example website! If you'd like to follow along, you can see the full code files in this repository.

User Database

The first thing that we need is a place to store user data. Because this is just an example, I decided to use SQLite so that anyone can download and easily use the source code.

In the GitHub repo, you'll see a Python file called create_database.py. This file contains functions that automatically create the example database for you. But if you don't want to test out the website for yourself, the only important thing to know is that the database file (users.db) has one table, that's created via the following SQL code:

CREATE TABLE IF NOT EXISTS users (
    username TEXT PRIMARY KEY,
    password TEXT NOT NULL,
    email TEXT
);

Making the username the primary key is fitting, since 1. everyone should have a unique username anyway, and 2. all of our queries to the database will search via username. I also decided to collect user emails; however, because this is meant to be a simple example, I didn't implement an email confirmation system since it would generally require a lot more code. Nevertheless, if you're interested in sending emails to your users, I've been happy using SendGrid's API.

Login Route

In this example, I'll be making a total of four Flask routes. The first one I'll discuss is the /login route. This is where users go to log in and get a valid session cookie.

The route is going to accept both GET and POST requests. When a user sends a GET request to the website, we can respond with the HTML/CSS for the page. But, when a user sends a POST request, we know that the request is coming from the HTML login form being submitted, at which point we'll verify the user's credentials. If the credentials check out, we will give them a session cookie and redirect the user to the home page ("/'), which, in this example, requires users to be logged in.

In more complex website's it would generally make sense to handle the form submission with Javascript; this approach would stop the page from refreshing. However, we're going down the simpler route here - It's less complicated but equally effective for what we need.

@app.route('/login', methods=['GET', 'POST'])
def login():

Handling HTTP Get Requests

Inside the login route function, we'll first add the code to handle a GET request. It's actually very simple - all we need to do is respond with the login.html page!

if request.method == 'GET':
    return render_template('login.html')

Again, the login.html and all other files are in the GitHub repository, but the most important part of the login.html page is the form element:

<form id="user-form" class="round-corners" action="/login" method="post">
    …
</form>

When a user clicks submit, the form will send the data as a POST request to the action route, which in this case is also “/login.”

Retrieving User Data

If we don't return the login.html page, the request must be a POST request, most likely coming from a form submission, where the user is trying to sign in.

The first thing that we need to do is get the user-submitted data from the form:

username = request.form.get('username')
password = request.form.get('password')

Then, we need to send a query to the user database, where we attempt to fetch the data associated with the username. I'm using Python's built-in SQLite3 package to query the database. After we execute the query, the "fetchone()" method is called and returns the one row in a Tuple.

query = 'select username, password, email from users where username = :username'

with contextlib.closing(sqlite3.connect(database)) as conn:
    with conn:
        account = conn.execute(query, {'username': username}).fetchone()

Verifying Username

If the query doesn't return any rows, "fetchone()" will return None; logically, this should only happen if the user submits a username that's not correct. We can handle this scenario with the below code:

if not account: 
    return render_template('login.html', error='Username does not exist')

“error” is an argument that Jinja (the templating engine that Flask uses) will see and add to the login.html page before responding to the user with it. Below is the Jinja code in the login.html page that does this:

{% if error %} <p id="error-msg">{{error}}</p> {% endif %}

Verifying Password

Now that we've queried the data and know that the username exists, we need to verify that the user submitted the right password. To do this, we will use the argon2-cffi package since this is what I'm using in the /register route to create the new password hashes.

We'll need to import the PasswordHasher object as well as the exception that we can catch if the passwords don't match when checking them with the verify() method:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

Then, we can use the following code to check if the user-submitted password is correct(account[1] is the hash string from the database result). If they don't match, we respond with the login.html page and an appropriate error message:

try: 
    ph = PasswordHasher()
    ph.verify(account[1], password)
except VerifyMismatchError:
    return render_template('login.html', error='Incorrect password')

Checking If Password Needs a Rehash

Once we make it here, we know that the passwords match! In the argon2-cffi documentation it suggests that every time a user logs in, while we have their password, we can check if any Argon2 parameters have been updated and thus need to be rehashed to stay up-to-date with best security practices. While this is optional, it never hurts to follow best practices!

If the hash needs to be updated, we rehash the password and update the user password in our database:

if ph.check_needs_rehash(account[1]):
    query = 'update set password = :password where username = :username'
    params = {'password': ph.hash(password), 'username': account[0]}

    with contextlib.closing(sqlite3.connect(database)) as conn:
        with conn:
            conn.execute(query, params)

Before using the flask.session object to create a signed cookie, we need to set the key that ItsDangerous will use (in the background) to sign the data.

In the app.py file, I set the app.secret_key value right after initializing the Flask object:

app = Flask(__name__)
app.secret_key = 'xpSm7p5fbgJY8rWfNoBjGWiSz5yjxM-NEBflW6SI362OkLc='

Now, we can set the session cookie for the user. I wrote a function in utils.py called “set_session” to handle the creation of session cookies. In the utils.py file, you'll see that I'm importing three objects from the Flask module:

from flask import redirect, url_for, session

The only one we're using now is the “session” object, but we will be using the other ones in the Login-required decorator section.

You can add anything you want to the session cookie. Just remember that any data you add will be sent with every user request; thus, if the cookie is very big, it'll take a bit more time for each request to get to your server. However, on the flip side, storing additional information in the cookie can cut down on future database queries. It's all about striking the right balance.

def set_session(username: str, email: str, remember_me: bool = False) -> None:
    session['username'] = username
    session['email'] = email
    session.permanent = remember_me

Another thing I've added to the login.html page is a “remember me” check box.


Remember Me Button Demo

By default, the flask.session object creates a "session" cookie, which expires when the browser session ends. However, sometimes a user wants to stay logged in for an extended period of time. Flask has a built-in "permanent" attribute for that; if you set it to True, Flask will change the cookie from a "session" cookie to one with an expiration date of 1 month from when the cookie was set!

Finally, we'll call our new function as such:

set_session(
    username=account[0], 
    email=account[2], 
    remember_me='remember-me' in request.form
)

Redirecting to Home Page

Lastly, we can redirect the user to the home page route, which as you'll see later, is login-required.

return redirect('/')

Register User Route

Now let's tackle the /register route. The first part is very similar to the Login route, in the sense that it will also handle both GET and POST requests:

@app.route('/register', methods=['GET', 'POST'])
def register():

And just like the /login route, GET requests will respond with the content of register.html:

if request.method == 'GET':
    return render_template('register.html')

Verifying User Data

The first thing we need to do is validate the user data. This is a lot more involved than the login route data verification. Below are some of the necessary data requirements, but there are many more that I didn't include. For example, the email input element in the HTML form is of the “email” type, which means that the browser automatically checks the validity of the email with some regex. However, in a real login system, you should also verify that the email is valid on the server-side, as someone could just send an HTTP request to the register route if they didn't want to go through the registration form.

# Store data to variables 
password = request.form.get('password')
confirm_password = request.form.get('confirm-password')
username = request.form.get('username')
email = request.form.get('email')

# Verify data
if len(password) < 8:
    return render_template('register.html', error='Your password must be 8 or more characters')
if password != confirm_password:
    return render_template('register.html', error_msg='Passwords do not match')
if not re.match(r'^[a-zA-Z0-9]+$', username):
    return render_template('register.html', error_msg='Username must only be letters and numbers')
if not 3 < len(username) < 26:
    return render_template('register.html', error='Username must be between 4 and 25 characters')

We also want to check that the username doesn't already exist, which requires a database query:

query = 'select username from users where username = :username;'

with contextlib.closing(sqlite3.connect(database)) as conn:
    with conn:
        result = conn.execute(query, {'username': username}).fetchone()

if result:
    return render_template('register.html', error_msg='Username already exists')

Hashing and Inserting Password into Database

If the user-submitted data checks out, the next thing that we need to do is hash their password so that we can safely store it in our database. We will use the same argon2-cffi Python object that we used in the login route to do the hashing, and then connect to the users database and insert a new row:

pw = PasswordHasher()
hashed_password = pw.hash(password)

query = 'insert into users(username, password, email) values (:username, :password, :email);'
params = {
    'username': username,
    'password': hashed_password,
    'email': email
}

with contextlib.closing(sqlite3.connect(database)) as conn:
    with conn:
        result = conn.execute(query, params)

Logging User In

Next, we will use the same set_sesison() function that we used in the /login route to automatically log the user in and then redirect them to the home page. We can do this because we aren't verifying user emails; however, if you want to have users confirm their email before accessing user-only parts of a site, you would not want to do this!

set_session(username=username, email=email)
return redirect('/')

Login-Required Decorator

Now that we've made the two main routes to this project, it's time to make a login-required route!

Essentially, all you need to do is check if “username” is in the Flask session object. I've found that the best way to do this is with a Python decorator, as it keeps the Flask routes nice and clean. Below is the code for the decorator in utils.py; all it's doing is checking that “username” is in the Flask session object. If the “username” is in the session object, the user must be logged in since when a user is not logged in or logs out, the session object will get cleared.

from flask import url_for, session
from functools import wraps


def login_required(func):
    @wraps(func)
    def decorator(*args, **kwargs):
        # Check if user is logged in
        if 'username' not in session:
            # User is not logged in; redirect to login page
            return redirect(url_for('login'))
            
        return func(*args, **kwargs)
    
    return decorator

Now, all you need to do is tack on the @login_required decorator AFTER the Flask app.route() decorator like such:

@app.route('/')
@login_required
def index():
    print(f User data: {session}')
    return render_template('index.html', username=session.get('username'))

Logout Route

The last route we need to make is one where users can sign out. When a user sends an HTTP request to the logout route, the session (basically just a Python dictionary) will be cleared of all its data and session.permanent will be set to False so that the cookie doesn't stay in the user's browser.

@app.route('/logout')
def logout():
    session.clear()
    session.permanent = False
    return redirect('/login')

Now that the session is empty, the @login_required decorator won't work because it checks for the existence of the “username” key!

Final Thoughts

While this should be a good start for anyone wanting to make their own user authentication system, there are a few considerations worth mentioning. To begin with, this article by no means covers every security feature that could be implemented when storing passwords. For example, another common layer of security to add is a “pepper,” which is similar to a salt in that it's also a random set of characters. However, a pepper is the same across all passwords and serves a different purpose. Furthermore, you should ask yourself: Do you even need to store user passwords? Storing passwords is a pain, and it's often a good idea to at least give your users the option to use your website by signing into another platform, such as Google, GitHub, or Facebook.

I hope this tutorial helped you understand how to make a simple login system in Flask. To fully understand and see all of the code, I'd recommend playing around with the code and try to implement the following features:

  • User Levels: Levels of users such as an admin level. You could add a specific key-value pair to the session cookie. Then, tweak the login_decorator to accept parameters, something like @login_required(level='admin'), to verify the user's access level.

  • Email Confirmation: You don't want people giving you fake emails!

  • Captcha: A captcha to prevent bots from easily creating accounts.

  • Login-Required Routes: Create some additional routes that require login by utilizing the @login_required decorator!

Remember, the best way to learn is by doing. Happy coding!