Users, Security, and Passwords
Learning Objectives
- You know of the principles of least privlege and zero trust.
- You know the terms authentication and authorization.
- You understand why password security is important and know how to hash and verify passwords.
- You can implement a basic authentication API with user registration and login functionality.
Users, Security, and Trust
Most applications have users who interact with the system in different ways. These users might have different roles, access levels, and permissions within the application. For example, in a blogging platform, some users might be able to write articles, while others can only read them. Some users might be administrators who can delete content, while regular users cannot.
We need some sort of control for what users can do and cannot do within an application. This is essential for maintaining the integrity of the data and the security of the system. Without proper controls, users could accidentally or intentionally modify or delete data that they should not have access to.
Technically, we could just trust everyone and allow everyone to do everything. This would mean that there would be no need for user accounts, passwords, or access controls. However, this approach is not practical for most applications:
- If someone’s account is compromised (e.g., their password is stolen), an attacker could gain full access to the system and cause significant damage.
- Users make mistakes. Someone might accidentally delete important data or make changes that break the application.
- Not all users have good intentions. Some might intentionally try to harm the system, steal data, or disrupt operations.
- Many applications handle sensitive data that must be protected by law (e.g., personal information, financial data, health records).
To address these concerns, we need security measures such as the principle of least privilege (users should only have access to what they need to perform their tasks) and zero trust principles (never trust, always verify). These approaches help minimize the potential damage from security breaches and user errors.
Security should be viewed as a culture that is a natural part of the application development process, not just a checklist of features to implement. It requires ongoing attention, regular updates, and a mindset that considers security implications at every step of development.
Authentication and Authorization
Authentication
Authentication refers to the process of identifying a user. Users can be identified through multiple means, including asking about something they know, asking for something they have, or checking for something they are.
Passwords are an instance of something the users know. Asking for something they have could involve a user’s mobile phone being sent a message or a code, checking that the mobile phone is at the user’s disposal. Similarly, checking for something they are could involve biometric authentication, such as using face, iris, or fingerprint recognition, or, for example, checking the identity of the user through keystroke dynamics.
The term 2-factor authentication refers to using at least two means to identify the user. This could involve, e.g., checking for a password (something they know) and verifying possession of the user’s phone (something they have).
In this course, we’ll work with just password-based authentication.
Authorization
Authorization refers to the process of determining what a user is allowed to do. For example, a user might be allowed to view a page, but not to edit it, or they might be allowed to view and edit a page, but not to delete it. Similarly, a user might be allowed to view a page, but only if they are authenticated (i.e., logged in to the system).
It could also be that the user’s access is restricted to a specific subset of data in the application, such as only their own data or the data that is specific to, for example, a specific group of users. As an example, in a bank application, a user should not be allowed to view (or modify) the accounts of all users, but only their own account. On the other hand, a user could be given access to a group of accounts, such as the accounts of all users in a group (e.g., their family).
Authorization is implemented on the server-side, where the server has logic that checks whether the user is allowed to perform the actions they are trying to perform. This can involve checking the routes that the user is trying to access, the methods that they are trying to use, and the data that they are trying to work with.
Authorization involves authentication, as knowledge of the user is required when trying to determine what they are allowed to do.
Working with Passwords
When working with passwords, it is important to understand that passwords are sensitive information. If a password is compromised, an attacker could gain access to the user’s account and potentially to other accounts if the user has reused the password.
Passwords should never be stored in plaintext format.
Here, when working with passwords, we use a library called scrypt. It provides an implementation of the scrypt algorithm. To add the scrypt library to your server-side application, modify the deno.json file to include the library. After the modification, the file should be as follows.
{
"imports": {
"@hono/hono": "jsr:@hono/hono@4.8.12",
"postgres": "npm:postgres@3.4.7",
"scrypt": "jsr:@denorg/scrypt@4.4.4"
}
}
Hashing a password
Passwords are stored as one-way hashes. This means that during storing of the password, the password is transformed to a hash (a string) using a cryptographic hash function (which should be non-trivial to invert).
The scrypt library provides a function called hash that can be used to create a hash of a password.
import { hash } from "scrypt";
const hashedPassword = hash("asparagus");
console.log(hashedPassword);
When running the above program, we see different outputs each time.
$ deno run app.js
c2NyeXB0ABEAAAAIAAAAAYF6ZbnPaea+wSlyjzks81oRqRdiPVyHm+2y/h7Hk4SYYaJUdrVYrFbelklXoNOER0NDPd7CFkXZVSBNnNfWXOM1ec5WVPToWnx9jF4uTMNq
$ deno run app.js
c2NyeXB0AA4AAAAIAAAAAX82yopYMZJyHkmR2qRYO/F/ykE6BwHrsfBxsleEQ37pqe3BjozKwB5vy/bDHjOSMTFWWZSTuAi1m2tQq22PFrjG9UWLXfmTcp8fkuSKA7WS
The reason why the output differs between each run is that the hash function automatically adds a random value called salt to the password before hashing it. This means that no two hashes are the same, even if the same password is hashed multiple times.
The salt is also appended to the hash, so that it can be used later when verifying the password.
The benefit of this is that if two users have the same password, they will not have the same hash. This makes it more difficult for attackers to use precomputed hash tables (e.g., rainbow tables) to crack leaked hash passwords.
Verifying a password
To verify a password, the password that a user enters is hashed and compared against a previously stored hash. During this process, the salt of the previously stored hash is extracted, and it is used for hashing the password that a user enters.
The scrypt library provides a function called verify that can be used to verify whether a password matches a previously hashed password.
import { hash, verify } from "scrypt";
const hashedPassword = hash("asparagus");
console.log(hashedPassword);
const passwordsMatch = verify("asparagus", hashedPassword);
console.log(passwordsMatch);
const passwordsDoNotMatch = verify("password", hashedPassword);
console.log(passwordsDoNotMatch);
The output of the above program is as follows, with the exception that the hash is different each time the program is run.
$ deno run app.js
c2NyeXB0ABEAAAAIAAAAAS2gleJXQhfvqC/1EKW2X7BHW82R1BB/+ppmiuMFEJcEnDAHwQbm4itDyA0pjvhaL80El8m3FUJR7wxVNAAyNrejHUcDeJkKg23N9x3bpzj9
true
false
Building an Authentication API
Next, let’s build an API for registering and authenticating users. The API will allow users to register with an email and password, and then log in with the same credentials.
The email will be used to identify the user, so it must be unique.
The structure of the authentication API will be as follows:
- POST
/api/auth/register- for registering a new user - POST
/api/auth/login- for logging in an existing user
We use layered architecture. A controller called authController.js will be responsible for handling the HTTP requests and responses, and a repository called authRepository.js will be responsible for interacting with the database. The controller will use the repository to perform the required operations.
Follow along the example in this part, building it also to your walking skeleton. At the end of each chapter, you’ll be asked to return a zip with the code at that point — this helps to ensure that you are following along and understanding the material.
Adding a table for users
First, create a migration file for the following users table, and run the migration. You may revisit the chapter PostgreSQL Database and Migrations to see how to create a migration file and run the migration. The migration file can e.g. be called V5__users.sql.
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX ON users (lower(trim(email)));
The users table has four columns: id, email, password_hash, and created_at. The id column is the primary key, which is an auto-incrementing integer. The email column is a text field that stores the email of the user, and it must be unique (case-insensitively). The password_hash column is a text field that stores the hashed password of the user.
The reason for the index on
lower(trim(email))is that we want to ensure that the email is unique, regardless of the case and leading/trailing spaces.
Auth repository
Next, implement the authentication repository. Create a file called authRepository.js in the root of the server-side application, and add the following code to it.
import postgres from "postgres";
const sql = postgres();
const create = async (user) => {
const result = await sql`
INSERT INTO users (email, password_hash)
VALUES (${user.email}, ${user.password_hash})
RETURNING id, email;
`;
return result[0];
};
const findByEmail = async (email) => {
const result = await sql`
SELECT * FROM users WHERE lower(trim(email)) = lower(trim(${email}))
`;
return result[0];
};
export { create, findByEmail };
The repository has two functions: create and findByEmail. The create function takes a user object with the email and password hash, and it inserts the user into the database. The findByEmail function takes an email and retrieves the user from the database based on the email.
Note how in the
createfunction we return only theidand
Auth controller
Next, implement the authentication controller. Create the file authController.js and add the following code to it.
import { hash, verify } from "scrypt";
import * as authRepository from "./authRepository.js";
const register = async (c) => {
const user = await c.req.json();
user.password_hash = hash(user.password);
const newUser = await authRepository.create(user);
return c.json(newUser);
};
const login = async (c) => {
const user = await c.req.json();
const foundUser = await authRepository.findByEmail(user.email);
if (!foundUser) {
return c.json({ error: "Invalid email or password" }, 401);
}
const isValid = verify(user.password, foundUser.password_hash);
if (!isValid) {
return c.json({ error: "Invalid email or password" }, 401);
}
return c.json({
message: "Login successful",
user: { email: foundUser.email },
});
};
export { login, register };
We intentionally show “Invalid email or password” for both cases where the user does not exist and where the password is incorrect. This is to avoid information leakage. For the registration, in practice, we would also want to send a confirmation email to the user to verify that they own the email address and reply in all cases with a generic message such as “A confirmation email has been sent to the email address”.
Wiring the controller to the routes
Finally, we need to wire the controller to the routes. Modify the app.js file to include the following code.
// ...
import * as authController from "./authController.js";
// ...
app.post("/api/auth/register", authController.register);
app.post("/api/auth/login", authController.login);
// ...
export default app;
Testing the API
Now, with the above in place, we can run the application and test the endpoints. Start the application and first try to create a user.
The command below uses curl to send a POST request to the /api/auth/register endpoint with the email and password in the request body.
curl -X POST -d '{"email": "test@test.com", "password": "secret"}' localhost:8000/api/auth/register
{"id":1,"email":"test@test.com"}
Then, we can try logging in as the user we just created.
$ curl -X POST -d '{"email": "test@test.com", "password": "secret"}' localhost:8000/api/auth/login
{"message":"Login successful","user":{"email":"test@test.com"}}
If we try logging in with an incorrect password, we see an error message.
$ curl -X POST -d '{"email": "test@test.com", "password": "turnip"}' localhost:8000/api/auth/login
{"error":"Invalid email or password"}
Summary
In summary:
- Users should not be trusted. Principles like least privilege and zero trust help minimize risks.
- Authentication is the process of verifying a user’s identity (e.g., through passwords).
- Authorization is the process of determining what an authenticated user is allowed to do.
- Passwords should never be stored in plaintext - always use cryptographic hashing with salt; which ensures that that identical passwords produce different hashes.