Building a user authentication system for a web or mobile app can be notoriously difficult, which is why there are dozens of open-source projects (e.g. Passport, Auth.js) and cloud-based services (e.g. Supabase, Clerk) dedicated to helping developers build secure and reliable user authentication.
For SwiftAce, however, I’ve chosen to implement authentication from scratch. I want to limit the external dependencies in the project to the absolute minimum, and I don't feel user authentication necessarily requires external libraries or cloud services. This post outlines the high-level design of the authentication system.
Most websites support various forms of authentication, like passwords, magic sign-in links, social logins, SMS one-time passwords, etc. For now, SwiftAce will only support one form of authentication (verification codes sent over email), but the underlying data model will establish a framework for adding other forms of authentication without breaking changes or disruptions.
There’s a single “Sign In / Sign Up” page that asks users for their email and includes a human verification check (CAPTCHA) powered by Cloudflare Turnstile:
Upon clicking “Continue”, the system checks if the user already has an account on the site. If the user does not already have an account, a new form is presented, asking for their first name, last name, and a 6-digit verification code which is sent over email:
If the user already has an account, they are simply asked to enter the verification code that is sent over email to sign in:
Using verification codes sent over email to authenticate the user eliminates the need for a username and password. It also removes the need for an additional “verify your email” step. Upon entering the verification code and clicking “Continue”, the user is signed in and redirected to the home page:
This is a simple, yet effective user authentication experience for getting things off the ground. Potential enhancements would include replacing “Email Address” with “Email or Phone” and adding social login options (Google, Apple, GitHub, etc.) on the first screen. At some point, we can also offer two-factor authentication for greater security.
SwiftAce uses cookie-based authentication to identify requests from logged-in users. Here’s how the entire authentication flow works:
The server-side logic and the user interface markup together account for less than 300 lines of code and can be found in the files login.jsx and auth.js.
Here’s the SQLite command used to create the users
database table for SwiftAce:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
first_name TEXT NOT NULL CHECK (length(first_name) < 128),
last_name TEXT CHECK (length(last_name) < 128),
avatar_url TEXT CHECK (length(avatar_url) < 1024)
);
Every user has a unique ID, autogenerated when the user is created, along with the timestamp created_at
. First name is the only necessary piece of information required to register a new user. The last name and profile picture (avatar) are optional.
The users
table does not contain a column for the user’s email address, which is stored in a separate table called user_emails
. It is created using the following SQLite command:
CREATE TABLE user_emails (
email TEXT PRIMARY KEY NOT NULL CHECK (length(email) < 256),
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
Storing user emails in a separate table allows associating multiple emails with a user account. It also allows for user accounts to be created without the need for an email. For instance, we could create another table user_phone_numbers
which can be used in conjunction with the users
table to implement phone-based authentication. Similarly, a table user_social_logins
could be used in conjunction with the users
table to implement social logins (Google, GitHub, etc.). Similarly, a table user_passkeys
could be used to implement passwordless login with passkeys.
Finally, here’s a table for keeping track of active user sessions:
CREATE TABLE user_sessions (
token_hash TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
This table stores a hash of the session token, instead of the token itself, to prevent user sessions from being hijacked if the contents of the user_sessions
are accidentally leaked. An alternate mechanism is JSON Web Tokens which do not require a database lookup, but they cannot be revoked.
And that’s everything it takes to build a user authentication system from scratch. It's simple, secure (as far as single-factor authentication systems go), and extensible. I hope you find this post useful for implementing user authentication in your own apps.