I've always been fascinated but authentication and security, but, like many, I've always hated passwords. I encountered fingerprint authentication on my Mac on eBay and thought it was the coolest thing ever. When Apple talked about Passkeys this year at WWDC, I figured that it was going mainstream, and it was time to figure out how it works. Here's what I figured out
I managed the python dependencies with poetry, so there's a
pyproject.toml and the Dockerfile with assume that poetry was used.
Last we are going to use ngrok to get a real url with SSL (required for WebAuthn) for testing. I find this much easier than configuring self-signed certificates. One site at a time is free.
First we need a Flask app. I'm going to stick all the source code inside an
app directory so the
docker-compose.yml isn't next to it. I'm not going into setting up python and a flask app here. Install flask and create:
from flask import Flask
app = Flask(__name__)
return "Hello, World"
You should probably
flask run to make sure everything is working.
WebAuthn requires a mix of persistent and ephemeral storage.
Obviously user accounts need to be persisted to the database or this is all pointless. We will also need to store some credentials in the database to validate the WebAuthn logins from certain users. We will be using PostgreSQL as the database and SQLAlchemy to access it. This is pretty standard for Flask apps.
We will also need to store some secrets temporarily. WebAuthn issues a challenge with each use, to prevent replay attacks. We will need to store the issued challenge, so it can be validated later. I'm also throwing in some email-based passwordless authentication, and we'll need to store some hashed secrets for that. You could probably just store these in a dictionary, but I think Redis provides a cleaner solution.
Let's set up the docker infrastructure now and ensure it is working, then we can get into the good stuff. We'll use waitress to serve the Flask app since that's the current recommendation, but I doubt that it matters much which wsgi server you use. We're also going to install the wait-for-it debian package to keep the app from starting before the database is up.
RUN apt-get update
RUN apt-get install -y wait-for-it
RUN pip install poetry
RUN pip install waitress
ADD . /app
RUN poetry export -o requirements.txt
RUN pip install -r requirements.txt
CMD sh ./entrypoint.sh
In the entrypoint file, we'll wait for the database, run the latest migrations, then start the server.
wait-for-it -t 10 db:5432 && \
flask db upgrade && \
waitress-serve --host 0.0.0.0 --port 5000 app:app
Make sure to change the permissions of the entrypoint file so that it is executable (
chmod +x app/entrypoint.sh on unix-like systems).
Finally, the docker-compose file to tie it all together. This file goes in the top level, not in the
app directory. You should probably have a
.env file with your database credentials and redis password, but I'm going to punt on that and be lazy and put them in the compose file. You definitely should keep the secret key out of it. It's just a demo. We'll make the app source directory a volume, so you don't have to rebuild on every change.
command: redis-server --requirepass 'supersecretredispassword'
We'll quickly do some setup for SQLAlchemy to make Flask-Migrate work, then we can finally
docker compose up and get into the real work. We'll create a new
models.py file in the
app directory to eventually house our models.
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
Now update the
app.py file to set up the database and migrations. Add imports for
Migrate as well as importing the
db object we created in
models.py. We will initialize it with configuration pulled from the environment (as set in
docker-compose.yml) and tell Flask-Migrate where the migrations will be. While we're here, let's add the secret key so that we can use the Flask session object in the future.
from flask import Flask
from flask_migrate import Migrate
from models import db
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
return "Hello, world!"
Now from within your project environment you can run
flask db init to create the migrations folder (make sure this gets committed to version control.)
Finally, you can run
docker compose up -d to build the app and get it online. Then you can go to http://localhost:5000 and should see our hello world page.
Now, in another window run
ngrok http 5000 and navigate to the url it shows, being sure to use https. Make sure to leave this window open or run it in the background with tmux or screen. Now you have a secure version of your website that won't make the browsers complain.
This was an annoying amount of setup, but this is a fairly complex process that requires some infrastructure. I promise at the end, when you get a browser to simply ask for your fingerprint to sign in, it will all be worth it.
Once these steps are complete, the project should look like this.
The good stuff is coming now, but this is really long. Head over to Part 2 to start building a website!