Handrolled Authentication Service

An exploration into handrolling my own authentication system, including session management

Jacob avatar
  • Jacob
  • 11 min read
Image generated by DALL.E

Introduction

Code

In this blog post, I continue the series of making things from scratch - learning how to handroll my own authentication service. The aim of this project was to learn how a typical authentication flow works and create a simple demo to be able to refer back to in the future. I explored methods to handle authentication and get around drawbacks, landing on a very common authentication pattern - using access and refresh JWTs.

This exploration was inspired by a YouTube video I watched by Ben Awad about handrolling an authentication service. I would highly recommend watching his video!

I have included some code along the way for illustrative purposes. This code is written in Rust, but these principles should apply to any language.

Please do not take this as security advice - only handroll your own authentication service if you know what you are doing.

The Problem

I wanted to create a working login system where the users can stay logged in for extended periods of time. This requires addressing two problems:

  • Authentication - checking the credentials of the user to ensure they are who they say they are. Their credentials can be their username and password, or some other form of identification such as a session token
  • Session management - keeping track of the users that are logged in, ensuring they stay logged in even after closing and reopening their browser

Authentication Flow

The typical flow of an authentication system is as follows:

  1. The user enters their username and password on the client side
  2. These credentials are sent to the server to be validated
  3. The server checks if the credentials are valid, and if they are, it will return some kind of unique token for that session
  4. The client stores this token and sends it in any future request that requires authentication. This way, we don’t need the user’s username and password on every network request

Authentication flow diagram

Initial Authentication

The focus of this project was not on validating usernames and passwords, but here is a brief overview of my setup:

// User data
#[derive(Debug, Clone)]
pub struct User {
    pub id: String,
    pub username: String,

    // Salted and hashed password
    pub password_hash: String,
}

impl User {
    // Utility function to check password
    pub fn check_password(&self, password: &str) -> bool {
        // Parse the password hash raw string
        let parsed_hash = PasswordHash::new(&self.password_hash).unwrap();

        // Use argon2 algorithm (from `argon2` crate) to verify salted & hashed password
        return Argon2::default()
            .verify_password(password.as_ref(), &parsed_hash)
            .is_ok();
    }
}

// Store a list of users in memory
pub struct Database {
    users: RwLock<Vec<User>>,
}

For more information on securely storing passwords, including salting and hashing, see here.

Session Management

We have several ways we can manage sessions. Two popular methods are session tokens and JWTs (JSON Web Tokens). Both of these methods start with the client sending the username and password to the server, followed by the server validating the credentials. If they are valid, we move on to the next step, which is where sessions and JWTs differ.

Sessions

The server typically creates a random 128-bit string as a unique ID for that session. This is stored in the database along with any extra information required about the session. It is also sent to the client, who stores it in a cookie to use in future requests.

On each request, the server will check if the session ID (from the cookie) exists in the database, and is still valid. This makes invalidating sessions easy - deleting the session record from the database will prevent future requests from being authorised with the same token.

While this is really simple and has a lot of flexibility, its stateful nature causes scalability issues. Before any request can be processed, there is the latency of reaching out to a database. It is also harder to distribute this system as the session database will need to be kept in sync across the distributed system.

JWTs

JWTs are also tokens that are provided to the client to store and use in future requests. However, they solve the problem of sessions by being stateless - the validity of a JWT can be checked without having to do a database check. The JWT does this by storing “claims” - a set of requirements the token must pass for it to be valid. There are several common claims, but they typically include at least an expiration time, who issued the token, and the subject e.g. the user ID. All of this is signed by the server to ensure that the token must have come from a trusted source.

Here’s how I make the tokens in Rust using the jsonwebtoken crate:

// Struct to store the claims of the JWT
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AccessClaims {
    sub: String,
    exp: usize,
    iss: String,
}

// Set claims
let access_claims = AccessClaims {
    // User ID
    sub: id.to_string(),
    // Expiration in 1 day
    exp: (SystemTime::now() + Duration::from_secs(24 * 60 * 60))
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs() as usize,
    // Issuer - a name to identify the api that generated the token
    iss: "handrolled-auth-api".to_string(),
};

// Encode token into a string
let access_token = encode(
    &jsonwebtoken::Header::default(),
    &access_claims,
    // Use a string as a key for the signing. You should use a key a lot stronger than this!
    &EncodingKey::from_secret("secret".as_ref()),
).unwrap();

More information:

To check that the JWT is valid, the server needs to check the signature, and then each of the claims. If everything passes, we know that the user has a valid session. The key is that these claims do not require database lookups, allowing them to be stateless.

To do this in my server, I use the following code:

// Read the token from the request cookies
let access_token = request.get_cookie("access_token").unwrap();

// Set up validation requirements (here, it's just the issuer)
let mut validation = Validation::default();
validation.set_issuer(&["handrolled-auth-api"]);

// Decode the token
let token = decode::<AccessClaims>(
    &access_token,
    &DecodingKey::from_secret("secret".as_ref()),
    &validation,
);

match token {
    Ok(token) => {
        // Handle valid token here
        println!("{:?}", token.claims); (e.g. return 401)
    },
    Err(e) => {
        // Handle invalid token here (e.g. return 401)
    },
}

JWTs can be decoded by anyone, so should not include any secret data. The signature on the JWT only ensures that a trusted source has created it, and it has not been tampered with (e.g. no one has changed the user ID or expiration date).

However, we now have a new problem - how are we meant to invalidate a session when JWTs are stateless?

Solving JWT’s Biggest Problem

Being able to invalidate a session is really useful for many reasons, for example, if a user’s permissions change, we need to invalidate the current token and give them a new one with the new permissions. With our current setup, we need to wait for the JWT to expire for the session to become invalid. To fix this, we could use a blacklist to invalidate the session early, but this means we would need a database lookup on every request.

Instead, we can make the expiry time sooner, say 5 minutes. Therefore, after 5 minutes, the user has to re-authenticate with their username and password, allowing us to do extra checks and change any permissions. However, this is really inconvenient - no one wants to put their username and password in every 5 minutes. But there is a solution to this - refresh tokens! These allow us to keep the short expiry, without requiring the user’s credentials often.

Refresh tokens are a long-living (e.g. 1 month) JWT that allows the user to request a new access token. We create this token at the same time as the original token, which we will now call the access token. When the access token expires, the client can use the refresh token to get a new access token. At this point, the server can do any extra checks or permission changes (checking the database if needed) before returning a new access token or refusing to do so. This has the benefit of keeping the access tokens stateless, and only requiring database checks a maximum of every 5 minutes.

The refresh token generation is exactly the same as the access token. We just create the extra token, and add another Set-Cookie header. Then, if we get an error while reading/validating the access token, we can move on to checking the refresh token. If successful, we create a new access token and return it to the client:

match access_token {
    Ok(access_token) => {
        // Same as before
    },
    // Access token was invalid or missing - check for refresh token
    Err(e) => {
        let refresh_token = req.get_cookie("refresh_token").unwrap();

        let claims = decode::<RefreshClaims>(
            &refresh_token,
            &DecodingKey::from_secret("secret".as_ref()),
            &validation,
        )
        .unwrap()
        .claims;

        // Find user in the database (by the ID in the token) to make sure they still exist
        let user = db.get_user_by_id(&claims.sub).expect("User not found");

        // Here we can do any extra checks we want to

        // Generate a new access token
        let token = generate_access_token(&user.id).unwrap();

        // Return new token with `Set-Cookie` header
    },
}

Now, how do we invalidate sessions? The way I chose to do this is to store a version in a claim on the refresh token. This will match a version stored in our database. To invalidate a session, we just need to increment the version in our database. Then, when we come to check the claims of the refresh token, it will fail the version claim, forcing the user to re-authenticate. Problem solved!

In practice, we first add a version property to the refresh token as well as the user:

// New refresh token claims struct - has `version` claim
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RefreshClaims {
    sub: String,
    exp: usize,
    iss: String,
    version: usize,
}

// New user struct - has current `session_version`
#[derive(Debug, Clone)]
pub struct User {
    pub id: String,
    pub username: String,
    pub password_hash: String,
    pub session_version: usize,
}

Then, we can check the session version after looking up the user in the database:

let user = db.get_user_by_id(&claims.sub).expect("User not found");

// Check the version in refresh token matches the database
if claims.version != user.session_version {
    // Return 401 if it doesn't match - log the user out
}

let token = generate_access_token(&user.id).unwrap();

Finally, invalidation is as simple as updating the user’s session version:

user.session_version += 1;

Refresh tokens do have a drawback - after invalidating the sessions, it may take up to 5 minutes (or the whatever lifespan of the access token is) for the user to be logged out.

Storing JWTs

There are several ways to store JWTs on the client side, but the most secure way is using HTTP-only cookies. These are cookies that cannot be accessed with JavaScript, which reduces our attack surface.

To store an HTTP-only cookie, the server must return a header called Set-Cookie with a value in the format <name>=<value>; HttpOnly. This tells the client to save the cookie, which it can then use in requests in the following way:

fetch("http://localhost:8080/endpoint", {
    // Include the HTTP-only cookies in the request
    credentials: "include",
});

There are several other settings you’ll probably want to configure for better cookie security. For example Secure only allows sending the cookie over HTTPS and SameSite=Lax only allows sending the cookie to the site it originated from. For more information, have a read of MDN Set-Cookie docs as well as the OWASP Session Management Cheat Sheet

Using HTTP-only cookies, the client now no longer knows who it is logged in as - it cannot read the JWT. The way to solve this is to have an endpoint on the server for fetching the user information. Since the server can read the JWT, it will know the ID of the user that made the request, and then can return relevant information.

Conclusion

And there we have it - a fully functioning authentication service, with session management and invalidation. Now you know the general flow of an authentication system, along with some of the problems they solve. If you would like to see some code, I have an example implementation of everything discussed here. This is written using the HTTP server I created in my previous post (HTTP from scratch), and ended up being only a few hundred lines of code!

It is important to remember that this may not be the best or most secure way to handle authentication, and more research should be done if you wish to handroll your own auth in production. It is often recommended to just use a library or pre-existing auth service that is written by people who are well-experienced in security. Despite that, I still think it’s really useful to learn how these services work at their core - that is the whole point of this “from scratch” series after all!

Resources

Jacob

Written by : Jacob

I am a software engineer that loves exploring tech and figuring out how things work. I am passionate about learning and hope to share some of that passion with you!