Implementing Register and Login in Cloudflare Workers with D1

Recently, I was building on a new Cloudflare Workers backend and needed a very simple authentication system, that would allow registration and login for a frontend application. After searching around I didn’t find a good example or tutorial about this topic, so I decided to build my own.
You can see the full code of this example in this authentication-using-d1-example github repo.
In this example, I will be using itty-router-openapi to generate OpenAPI 3 compliant schemas and validate the input parameters and workers-qb to generate SQL queries that will make the code more readable.
Table of Contents
- Project setup
- Users register endpoint
- Users login endpoint
- Authenticating routes
- Application router
- Final Result
1. Project setup
First, install dependencies with the following commands.
npm install --save-dev wrangler@^3.8.0
npm install --save workers-qb@^1.1.1
npm install --save @cloudflare/itty-router-openapi@^1.0.3
For this project you will need to define a SALT_TOKEN
that is used in the password hash generation, this can be any
string and you can generate it in a password generator or just write something yourself. It is also recommended, that
after you follow this tutorial you move this variable from the wrangler.toml
into a Cloudflare secret.
You will also need a D1 database, but this can always be adapted to use another database or your own PostgreSQL instance.
After you have installed and logged in wrangler
, go ahead and create a new D1 database with the following command.
wrangler d1 create <db-name> --experimental-backend
Update your wrangler.toml
with the SALT_TOKEN
and D1 binding.
name = "d1-auth-example"
main = "src/index.ts"
compatibility_date = "2023-05-27"
[[d1_databases]]
binding = "DB"
database_name = "d1-auth-example"
database_id = "e90ca6b1-4de8-4b88-821a-0b7af3e40dc2"
[vars]
SALT_TOKEN = "sadfasdghjyt45645t"
Now bootstrap your database by creating the initial tables for storing users and user sessions.
wrangler d1 execute DB --local --command="create table users
(
id integer primary key autoincrement,
email text not null unique,
password text not null,
name text not null
);"
wrangler d1 execute DB --local --command="create table users_sessions
(
session_id integer primary key autoincrement,
user_id integer not null
constraint users_sessions_users_id_fk
references users
on update cascade on delete cascade,
token text not null,
expires_at integer not null
);"
2. Users register endpoint
The password must not be saved in raw text in the database and for this, we can use a very simple SHA-256
to generate
a hash that represents the password.
This function receives the raw password + the SALT_TOKEN we defined earlier to generate a unique token.
async function hashPassword(password: string, salt: string): Promise<string> {
const utf8 = new TextEncoder().encode(`${salt}:${password}`);
const hashBuffer = await crypto.subtle.digest({name: 'SHA-256'}, utf8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0'))
.join('');
}
Then in the actual endpoint code we just need to call this function when we are inserting the new user.
import { Email, OpenAPIRoute } from '@cloudflare/itty-router-openapi';
import { z } from 'zod'
export class AuthRegister extends OpenAPIRoute {
static schema = {
tags: ['Auth'],
summary: 'Register user',
requestBody: {
name: String,
email: new Email(),
password: z.string().min(8).max(16),
},
responses: {
'200': {
description: "Successful response",
schema: {
success: Boolean,
result: {
user: {
email: String,
name: String
}
}
},
},
'400': {
description: "Error",
schema: {
success: Boolean,
error: String
},
},
},
};
async handle(request: Request, env: any, context: any, data: Record<string, any>) {
let user
try {
user = await context.qb.insert({
tableName: 'users',
data: {
email: data.body.email,
name: data.body.name,
password: await hashPassword(data.body.password, env.SALT_TOKEN),
},
returning: '*'
}).execute()
} catch (e) {
return new Response(JSON.stringify({
success: false,
errors: "User with that email already exists"
}), {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
status: 400,
})
}
return {
success: true,
result: {
user: {
email: user.results.email,
name: user.results.name,
}
}
}
}
}
Here you can see that we are defining the requestBody
that are our endpoint parameters and the responses
This will
be used by itty-router-openapi
to validate the input parameters before our code gets executed.
Then inside the handle
function we are trying to insert a new user, if an exception is raised during this, it is very
likely due to the uniqueness of the email, the user is trying to register.
3. Users login endpoint
Now for the login endpoint, all we need to do is call the function that generates the password hash from the raw
password and the SALT_TOKEN
and check against the database, if a user is found, generate a session token and return it.
import { Email, OpenAPIRoute } from '@cloudflare/itty-router-openapi';
import { z } from 'zod'
export class AuthLogin extends OpenAPIRoute {
static schema = {
tags: ['Auth'],
summary: 'Login user',
requestBody: {
email: new Email(),
password: z.string().min(8).max(16),
},
responses: {
'200': {
description: "Successful response",
schema: {
success: Boolean,
result: {
session: {
token: String,
expires_at: String
}
}
},
},
'400': {
description: "Error",
schema: {
success: Boolean,
error: String
},
},
},
};
async handle(request: Request, env: any, context: any, data: Record<string, any>) {
const user = await context.qb.fetchOne({
tableName: 'users',
fields: '*',
where: {
conditions: [
'email = ?1',
'password = ?2'
],
params: [
data.body.email,
await hashPassword(data.body.password, env.SALT_TOKEN)
]
},
}).execute()
if (!user.results) {
return new Response(JSON.stringify({
success: false,
errors: "Unknown user"
}), {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
status: 400,
})
}
let expiration = new Date();
// Set the new session expiration to 7 days in the future
expiration.setDate(expiration.getDate() + 7);
const session = await context.qb.insert({
tableName: 'users_sessions',
data: {
user_id: user.results.id,
token: await hashPassword((Math.random() + 1).toString(3), env.SALT_TOKEN),
expires_at: expiration.getTime()
},
returning: '*'
}).execute()
return {
success: true,
result: {
session: {
token: session.results.token,
expires_at: session.results.expires_at,
}
}
}
}
}
4. Authenticating routes
Now for authenticating users, we just need to validate that the frontend client is sending a session token, then check if is in the database and it has not expired yet.
As said above, this is a very simple authentication system, and this can be improved if necessary, a very good improvement could be to instead of using a string token for sessions, use a JWT to encapsulate the token, then this authentication function could validate that the session token is valid without querying the database and improving the performance of the application.
export function getBearer(request: Request): null | string {
const authHeader = request.headers.get('Authorization')
if (!authHeader || authHeader.substring(0, 6) !== 'Bearer') {
return null
}
return authHeader.substring(6).trim()
}
export async function authenticateUser(request: Request, env: any, context: any) {
const token = getBearer(request)
let session
if (token) {
session = await context.qb.fetchOne({
tableName: 'users_sessions',
fields: '*',
where: {
conditions: [
'token = ?1',
'expires_at > ?2',
],
params: [
token,
new Date().getTime()
]
},
}).execute()
}
if (!token || !session.results) {
return new Response(JSON.stringify({
success: false,
errors: "Authentication error"
}), {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
status: 401,
})
}
// set the user_id for endpoint routes to be able to reference it
env.user_id = session.results.user_id
}
Here you can see that we are expecting the session token to be placed in the Authorization
header, but this piece
can easily be adapted to expect the session token to be inside a cookie or anywhere needed.
5. Application router
To wrap things up, it’s just needed to setup the application router that tight things together.
Due to limitations in the current implementation of itty-router-openapi
, it’s very important that the endpoint routes
are defined in the following way:
- Endpoints that don’t require Auth
- Authentication middleware
- Endpoints that require Auth
For the testing purposes we will also create a search endpoint that retrieves repos from the github api.
export class GetSearch extends OpenAPIRoute {
static schema = {
tags: ["Search"],
summary: "Search repositories by a query parameter",
parameters: {
q: Query(String, {
description: "The query to search for",
default: "cloudflare workers",
}),
},
responses: {
"200": {
description: "Successful response",
schema: {
repos: [
{
name: "itty-router-openapi",
description:
"OpenAPI 3 schema generator and validator for Cloudflare Workers",
stars: "80",
url: "https://github.com/cloudflare/itty-router-openapi",
},
],
},
},
"401": {
description: "Not authenticated",
schema: {
"success": false,
"errors": "Authentication error"
},
},
},
};
async handle(request: Request, env, ctx, data: Record<string, any>) {
const url = `https://api.github.com/search/repositories?q=${data.query.q}`;
const resp = await fetch(url, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "RepoAI - Cloudflare Workers ChatGPT Plugin Example",
},
});
if (!resp.ok) {
return new Response(await resp.text(), { status: 400 });
}
const json = await resp.json();
// @ts-ignore
const repos = json.items.map((item: any) => ({
name: item.name,
description: item.description,
stars: item.stargazers_count,
url: item.html_url,
}));
return {
repos: repos,
};
}
}
And now the router code that defines all paths to endpoints.
import { OpenAPIRouter } from "@cloudflare/itty-router-openapi";
import { GetSearch } from "./search";
import { authenticateUser, AuthLogin, AuthRegister } from "./auth";
import { D1QB } from "workers-qb";
export const router = OpenAPIRouter({
schema: {
info: {
title: "Authentication using D1",
version: '1.0',
},
security: [
{
bearerAuth: [],
},
],
},
docs_url: "/",
});
router.registry.registerComponent('securitySchemes', 'bearerAuth', {
type: 'http',
scheme: 'bearer',
})
// 1. Endpoints that don't require Auth
router.post('/api/auth/register', AuthRegister);
router.post('/api/auth/login', AuthLogin);
// 2. Authentication middleware
router.all('/api/*', authenticateUser)
// 3. Endpoints that require Auth
router.get("/api/search", GetSearch);
// 404 for everything else
router.all("*", () => new Response("Not Found.", { status: 404 }));
export default {
fetch: async (request, env, ctx) => {
// Inject query builder in every endpoint
const qb = new D1QB(env.DB)
// qb.setDebugger(true)
return router.handle(request, env, {...ctx, qb: qb})
},
};
6. Final Result
Now when executing the project and opening the browser at http://127.0.0.1:8787/ you will see a swagger interface were you can test the endpoints.
Then you may call the search test endpoint, and be greeted with the error message saying that you are not authenticated.
Then after calling the register endpoint with the desired credentials and then calling the login endpoint with the same credentials as before, you will receive a bearer token and an expiration date.
Now use the token to set your authentication in the swagger.
Now calling the search test endpoint will actually return results.
You can see the full code of this example in this authentication-using-d1-example github repo.