Authentication is a broad topic that can be both very simple and very complicated. In this series we'll describe what it is and different approaches to implement it, starting from older less-secure methods and ending with modern more-secure methods.
Authentication is the process of determining who someone or something is. In the real world, when you give someone your ID or passport, that's a form of authentication. Online, when you enter an email/password, that's a form of authentication.
While authentication processes can also be for machine-to-machine communication, in this post we'll focus on users identifying themselves to a web server - a.k.a. user authentication.
To simplify the problem, we need two processes:
- Some way to create new users
- Some way to identify existing users
Let's look at a few approaches to do these and point out what's good and bad about each approach. Let's assume we are building a web app, and we have both a server to handle requests and a database to store data.
When a new user wants to sign up, we'll ask for their email address and password and save those to the DB. Typically, we also create a user ID so that users can change their email address but their user ID won't change.
A user wants to make a request to our server. The only pieces of information we have are their email address, user ID, and password. What if we just ask them to send us their email address and password again on each request?
When we get a request, we verify the email address exists in our database and the password is correct.
This is called Basic Authentication (technically basic authentication requires the email/password to be sent in a specific format in the header, but the idea is still the same - the request contains the email and password).
On the plus side, this system is very simple... one might even call it basic.
That being said, every time a user wants to do anything, they need to specify their email address and password. It's bad practice to save a user's password somewhere in the browser (like
localStorage), and asking the user to re-enter their password every single time they want to do anything is a terrible user experience.
This approach isn't completely worthless though. Remember, authentication is a broad topic, and while I wouldn't implement a system where users are constantly passing in their email/password in a browser, if I was building a service where the requests primarily come from a terminal instead, saving someone's email and password to a file or keychain and passing it along on every request is definitely more reasonable.
We don't want to ask our users to keep entering their password, so we need something else they can send to us to prove they are who they say they are.
We do also have a user id... and we can make that user id as random as we like. What if we keep our signup method the same, but return the user id to the client and ask them to return it on each request.
Assuming user ids are generated with a cryptographically-secure pseudorandom number generator, they should be unguessable, and we should be fine here... right?
This approach is actually worse than our first attempt. One reason why is user ids aren't supposed to change. If a user's id is leaked, we have no way to prevent attackers from accessing that users account, forever. In our last attempt, if someone's password was stolen, they could have changed it. Now, however, they need a new account with a different user ID.
We did say that user IDs were unguessable but there are unfortunately ways where it could get leaked. Maybe the user used an insecure password and someone guessed it and therefore got their user id. Maybe they logged in on a shared computer with malware on it. We have unfortunately no good ways to protect our users once their user ID is out.
Ok, so we don't want to send passwords on each request, and we don't want to send a user ID on each request because we can't easily revoke it.
What if, instead of asking the user to send us a user ID, we create a new unguessable token and map it to the user's ID?
If someone steals our token, we can delete it from our database and our user would have to log in again. We can even add an expiration time, after which the token is considered invalid.
These tokens are often called session tokens, and they are typically stored in cookies. These tokens are also called opaque tokens, because the string itself has no meaning outside our database.
This a way more reasonable approach. After a user logs in, they have an unguessable token which only they have. It's not sensitive like a password. If a user wants to log out or if their phone/laptop gets stolen, we can just delete the token from our database.
The main con is that this is a stateful approach. Verifying a session token requires us to do a database lookup. Some platforms like Vercel have solutions like Next.js API Routes which boast that they can be run globally at the edge - meaning as close to the user as possible. If you need to do a database lookup in a database in a fixed location, you can lose some of the latency advantages you gain by globally hosting.
If we want to avoid the database lookup, instead of issuing tokens that are saved to our database, we can issue JWTs. We have a separate article dedicated to understanding JWTs here.
So far, everything we've mentioned is hopefully pretty straightforward. The difficulty in user authentication doesn't come from the approach, it comes from all the tiny details. For example:
- When you store passwords, you need to make sure they are stored securely. See our article about that here.
- Best practice for user authentication is to deny commonly used passwords and passwords seen in previous data breaches.
- It's easy to introduce subtle timing attack vulnerabilities, either in verifying the password or in supplementary workflows like forgotten password flows.
- There are guidelines about the error messages you display to avoid leaking email addresses of your users.
- There have been multiple vulnerabilities in JWT libraries themselves.
- If you are using cookies, they should be HTTP-only and secure.
And the list goes on.
Services like PropelAuth exist so you can avoid wasting time worrying about user authentication and get back to just building your product.
In future posts in this series, we'll look at how things like social logins and two-factor authentication work.