Create a Secure Flask Login System Using Argon2 Hashing
Posted on • Last updated on
In this article, I’m going to explain how to make a simple authentication system that uses Flask, Argon2 for password hashing, signed cookies for session management, and an SQLite database to store user data. If you’d 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:
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 the most sensitive columns, like the user's password, are 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. 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 the 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 argon2-cffi for this article.
When we hash a password with Argon2, the result is a string that has the following components:
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 even if the underlying passwords are the same, 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.
Cookie Basics
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 Session Cookie
Flask has a handy class called “session” which is an easy-to-use interface for handling session data; It can be used in a similar way 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 signature as well as the original data (<encoded_data>.<encoded_signature>).
This method works great because, while the user can see the 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 works because 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, but 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 websites 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 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')
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’ll be 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 Passwords 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)
Setting a Session Cookie
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 = 'EXAMPLE_xpSm7p5bgJY8rNoBjGWiz5yjxMNlW6231IBI62OkLc='
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.
The three important keys that I added to the session are iat
, which denotes the time that the session was created, and exp
, which says what time the token should expire at. Later, you’ll see that the login-required routes will not let you access them if the session has expired. This is crucial since, otherwise, an attacker would be able to use an old session cookie forever! Lastly, I added the username
to the cookie since this will let the web server look up more info about each user if it’s needed.
DEFAULT_EXPIRATION_TIME = timedelta(days=1)
REMEMBER_ME_EXPIRATION_TIME = timedelta(days=15)
def set_session(username: str, remember_me: bool = False) -> None:
session['username'] = username
session['iat'] = datetime.now(timezone.utc).isoformat()
exp_time_offset = REMEMBER_ME_EXPIRATION_TIME if remember_me else DEFAULT_EXPIRATION_TIME
session['exp'] = (datetime.now(timezone.utc) + exp_time_offset).isoformat()
session.permanent = remember_me
Another thing I’ve added to the login.html
page is a “remember me” check box. By default, the flask.session
object creates a “session” cookie, which disappears 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! However, I think that 30 days is too long to keep a user logged in for, so I changed it to 15 days via this line of code in app.py
:
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=15)
Now that we are able to set session cookies, at the bottom of the /login
route, we can set the user cookie via the following function call:
set_session(
username=account[0],
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 production-grade authentication 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='Passwords do not match')
if not re.match(r'^[a-zA-Z0-9]+$', username):
return render_template('register.html', error='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_session
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)
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 page!
To efficiently check if a user has the correct access permissions, I’ve taken some concepts from Json Web Tokens and OAuth2.0. First the decorator checks if the token has expired via the exp
key in the session cookie. If the session has expired, then the user is redirected to the login page.
If the token has not expired, the next thing that we want to do is check our user database to make sure that the user still has their permissions / has not been removed from the database. The code will query the user database to check the users permissions every 30 minutes. By only periodically checking the users permissions, we can reduce the number of database queries made while also making sure that users still have access to what they are requesting.
Once we have determined that the user still has their permissions, we can call the function that was protected behind the decorator!
ACCESS_TOKEN_LIFETIME = timedelta(minutes=30)
DEFAULT_EXPIRATION_TIME = timedelta(days=1)
REMEMBER_ME_EXPIRATION_TIME = timedelta(days=15)
def login_required(func):
@wraps(func)
def decorator(*args, **kwargs):
try:
expiration_time = datetime.fromisoformat(session.get('exp'))
except (TypeError, ValueError):
return redirect(url_for('login')) # Something wrong with Expiration value
if expiration_time < datetime.now(timezone.utc):
return redirect(url_for('login'))
try:
issued_at = datetime.fromisoformat(session.get('iat'))
except (TypeError, ValueError):
return redirect(url_for('login')) # Something wrong with Issued At value
if issued_at + ACCESS_TOKEN_LIFETIME < datetime.now(timezone.utc):
query = 'select username from users where username = :username;'
with contextlib.closing(sqlite3.connect('users.db')) as conn:
with conn:
result = conn.execute(query, {'username': session.get('username', '')}).fetchone()
if not result:
return redirect(url_for('login')) # No user with that username in the db anymore
session['iat'] = datetime.now(timezone.utc).isoformat() # Reset the Issued At parameter
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!
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 added to a password for hashing. 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!