Using an Authentication Library
Learning Objectives
- You know that there are authentication libraries and you know arguments for and against using them.
- You can integrate an authentication library (in our case, Better Auth) into a web application.
In the previous chapters, we learned how to build an authentication system from scratch, including user registration, login, JWT tokens, authorization, and role-based access control. This has helped us to form a fundamental understanding of how authentication and authorization works.
However, in production applications, developers typically don’t build authentication systems from scratch. Instead, they use established libraries and services. Here, we look into using Better Auth.
We start by setting Better Auth up for the server, and then we update the client to use Better Auth. Finally, we look into how to extend Better Auth to work with our existing roles system.
Better Auth uses sessions stored in the database instead of JWTs. This is a more traditional approach, and it has some advantages over JWTs, including easier token revocation: if a token is compromised, it can be invalidated by deleting the session from the database.
Here, mainly walk through the integration of Better Auth, and we won’t go into the details of the pros and cons of different authentication approaches.
Server-side integration
Installation
First, install Better Auth and its dependencies. In your server directory, run:
$ deno install npm:better-auth@1.3.27 npm:kysely-postgres-js@3.0.0
This installs specific versions of Better Auth library and the Kysely Postgres driver. After running the command, the deno.json file should be (similar) to the following:
{
"imports": {
"@hono/hono": "jsr:@hono/hono@4.8.12",
"better-auth": "npm:better-auth@1.3.27",
"kysely-postgres-js": "npm:kysely-postgres-js@3.0.0",
"postgres": "npm:postgres@3.4.7",
"scrypt": "jsr:@denorg/scrypt@4.4.4"
}
}
The
npmprefix in the dependency is used to indicate that Deno should download the package from npm.
Database schema
Better Auth has a specific database schema that it expects. This means that we need to make quite a few adjustments to our existing database schema. Let’s first create a migration file to drop tables that we have created for our custom authentication system, including the table in which we stored book reading progress.
Create a new migration file called V8__cleanup_custom_auth.sql:
DROP TABLE IF EXISTS reading_progress;
DROP TABLE IF EXISTS user_roles;
DROP TABLE IF EXISTS users;
Run the migration file.
In a production system, you would typically migrate existing user data to the new schema. However, for simplicity, we will drop the existing tables and start fresh.
Then, create a new migration file to set up the database schema required by Better Auth. Create a file called V9__initial_better_auth_schema.sql, and place the following into the file:
CREATE TABLE users (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
email_verified BOOLEAN NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE sessions (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL REFERENCES users(id),
token VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(255),
user_agent VARCHAR(255),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE accounts (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL REFERENCES users(id),
account_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
password VARCHAR(255),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
The above follows the core database schema of Better Auth, with some omissions.
It would be possible to also use Better Auth for creating the database tables. However, as we’ve just learned to write database migrations, let’s stick to writing our own migrations.
Run the migration to create the tables.
Finally, create a migration file for adding roles to users. Create a file called V10__user_roles.sql with the following content:
CREATE TABLE user_roles (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id VARCHAR(255) NOT NULL REFERENCES users(id),
role TEXT NOT NULL,
UNIQUE(user_id, role)
);
Now, run the migration again. At this point, your database schema is pretty much ready. For the purposes of this example, we omit the recreation of the reading progress table.
Configuring Better Auth
Next, create a new file called betterAuth.js in the server directory to configure Better Auth. Add the following content to the file.
import { betterAuth } from "better-auth";
import { PostgresJSDialect } from "kysely-postgres-js";
import postgres from "postgres";
import { bearer, customSession } from "better-auth/plugins";
import { createAuthMiddleware } from "better-auth/api";
const sql = postgres();
const dialect = new PostgresJSDialect({
postgres: sql,
});
const getUserRoles = async (userId) => {
const result = await sql`
SELECT role FROM user_roles WHERE user_id = ${userId}
`;
return result.map((row) => row.role);
};
export const auth = betterAuth({
database: {
dialect: dialect,
type: "postgresql",
},
emailAndPassword: {
enabled: true,
autoSignIn: false,
},
trustedOrigins: ["http://localhost:5173"],
user: {
modelName: "users",
fields: {
emailVerified: "email_verified",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
session: {
modelName: "sessions",
fields: {
userId: "user_id",
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
ipAddress: "ip_address",
userAgent: "user_agent",
},
},
account: {
modelName: "accounts",
fields: {
userId: "user_id",
accountId: "account_id",
providerId: "provider_id",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
hooks: {
after: createAuthMiddleware(async ({ path, context }) => {
if (path?.includes("/sign-in")) {
const newSession = context.newSession;
if (!newSession) {
return;
}
const userId = newSession.user.id;
const roles = await getUserRoles(userId);
newSession.user.roles = roles;
if (context.returned && context.returned.user) {
context.returned.user.roles = roles;
}
}
}),
},
plugins: [
bearer(),
customSession(async ({ user, session }) => {
const roles = await getUserRoles(session.userId);
return {
user: {
...user,
roles,
},
session,
};
}),
],
});
There’s a lot going on, but in essence:
- We connect to the PostgreSQL database using
postgresandkysely-postgres-js. - We define a helper function
getUserRolesto fetch user roles from the database. - We configure Better Auth to:
- Use PostgreSQL
- Use email and password authentication
- Trust our frontend origin
- Use the database schema we created above, including custom field names. By default, Better Auth would expect singular table names (e.g.,
userinstead ofusers) and camelCase field names instead snake_case field names (e.g.,emailVerifiedinstead ofemail_verified). - Add roles to the user object after sign-in using a hook.
- Add roles to the user object whenever a session is verified using a custom session plugin.
- Use bearer token authentication and custom session handling.
There’s no need to understand what’s going on in detail. In essence, we’re just configuring Better Auth to work with our database and to include roles in the user object.
Integrating with Hono
Now let’s integrate Better Auth with Hono. Update your app.js to allow CORS requests from http://localhost:5173 (the user interface) with credentials:
// ...
app.use(
"/*",
cors({
origin: "http://localhost:5173",
credentials: true,
}),
);
Then, import the auth object from betterAuth.js, and replace the /api/auth routes that used the register and login functions from authController.js with the following:
// ...
import { auth } from "./betterAuth.js";
// ...
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
// ...
Now, login and registration requests (that we’ll make from the client) will be handled by Better Auth.
Finally, we need to update the authentication middleware to use Better Auth for verifying sessions. Replace the existing authenticate function in middlewares.js with the following, importing auth from betterAuth.js to the file.
import { auth } from "./betterAuth.js";
const authenticate = async (c, next) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers
});
if(!session || !session.user) {
return c.json({ error: "Authentication required" }, 401);
}
c.set("user", session.user);
await next();
};
// ...
The above uses Better Auth to verify the session from the Authorization header. If the session is valid, it adds the user object to the context. The difference between how Better Auth handles this and how we handled this earlier is that the token will no longer contain the roles (it’s not a JWT at all), but instead, Better Auth will fetch the roles from the database and add them to the user object whenever a session is verified.
Now, in theory, everything should be set up on the server side. Let’s move on to updating the client.
Updating the client
Now let’s update the client to use Better Auth. Better Auth provides a client library that makes authentication simple.
Installing Better Auth client
In your client directory, install the Better Auth client:
$ deno install npm:better-auth@1.3.27
This adds better auth as a dependency to your package.json file.
Creating an auth client
To use Better Auth on the client, we need to create an auth client. Create a new file src/lib/utils/authUtils.js and place the following content to the file.
import { createAuthClient } from "better-auth/svelte";
import { PUBLIC_API_URL } from "$env/static/public";
const authClient = createAuthClient({
baseURL: PUBLIC_API_URL,
});
export { authClient };
This creates an auth client that points to our API URL.
Updating the authentication state
Next, we need to replace the login functionality in authState.svelte.js to use Better Auth. The key functions that the authClient provides are signIn.email, signUp.email, and signOut.
The signIn.email function takes two objects as parameters: the first is an object with email and password properties and a callbackURL to redirect to after login, and the second object has handlers for onSuccess and onError. We’ll use the onSuccess to update our state — in particular, the response token is in a header called set-auth-token, and the user is in the data property of the response context. Our full replacement for the earlier login function looks like this:
login: async (email, password) => {
await authClient.signIn.email({
email,
password,
callbackURL: "/",
}, {
onError: (ctx) => {
throw new Error(ctx.error.message || "Login failed");
},
onSuccess: (ctx) => {
const authToken = ctx.response.headers.get("set-auth-token");
if (authToken) {
localStorage.setItem(TOKEN_KEY, authToken);
}
const user = ctx.data.user;
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
},
});
},
Note that we still use localStorage to store the token and user. This way, our existing functionality that rely on them, including the
authFetchhelper function, will continue to work.
The signUp.email function takes similarly two objects as parameters, where the first one has email, password and name, while the second has success and error handlers. In our case, we’ll pass the email as the name, and use both an error handler and a success handler. The modified register function will look like this:
register: async (email, password) => {
await authClient.signUp.email({
email,
password,
name: email,
callbackURL: "/auth/login",
}, {
onError: (ctx) => {
throw new Error(ctx.error.message || "Registration failed");
},
onSuccess: (ctx) => {
window.location.href = "/auth/login";
},
});
},
The success handler redirects the user to the login page. In principle, the
callbackURLshould handle this, but at least at the time of writing these materials, with Better Auth 1.3.27, there seems to be a bug in the redirect functionality.
Finally, the signOut function takes no parameters. It invalidates the session on the server and removes the token from the Better Auth client. As we store the token and user in localStorage, we also need to remove them. The modified logout function will look like this:
logout: async () => {
user = null;
token = null;
await authClient.signOut();
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
window.location.href = "/";
},
Jointly, the full authState.svelte.js is as follows:
import { browser } from "$app/environment";
import { authClient } from "$lib/utils/authUtils.js";
const USER_KEY = "user";
const TOKEN_KEY = "token";
let user = $state(null);
let token = $state(null);
if (browser) {
const storedUser = localStorage.getItem(USER_KEY);
const storedToken = localStorage.getItem(TOKEN_KEY);
if (storedUser) {
user = JSON.parse(storedUser);
}
if (storedToken) {
token = storedToken;
}
}
const useAuthState = () => {
return {
get user() {
return user;
},
get token() {
return token;
},
login: async (email, password) => {
await authClient.signIn.email({
email,
password,
callbackURL: "/",
}, {
onError: (ctx) => {
throw new Error(ctx.error.message || "Login failed");
},
onSuccess: (ctx) => {
const authToken = ctx.response.headers.get("set-auth-token");
if (authToken) {
localStorage.setItem(TOKEN_KEY, authToken);
}
const user = ctx.data.user;
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
},
});
},
register: async (email, password) => {
await authClient.signUp.email({
email,
password,
name: email,
callbackURL: "/auth/login",
}, {
onError: (ctx) => {
throw new Error(ctx.error.message || "Registration failed");
},
onSuccess: (ctx) => {
window.location.href = "/auth/login";
},
});
},
logout: async () => {
user = null;
token = null;
await authClient.signOut();
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
window.location.href = "/";
},
};
};
export { useAuthState };
Updating the login and registration form
We need to also update the login and registration form (in src/routes/auth/[action]/+page.svelte) to match the updates. The changes are minimal — now, as we handle redirects in the authState, we don’t need to do that in the form.
Modify the file to match the following:
<script>
import { useAuthState } from "$lib/states/authState.svelte.js";
let { data } = $props();
let message = $state("");
let errorMessage = $state("");
let isLoading = $state(false);
const authState = useAuthState();
const handleForm = async (e) => {
e.preventDefault();
errorMessage = "";
message = "";
isLoading = true;
const formData = new FormData(e.target);
const { email, password } = Object.fromEntries(formData);
try {
if (data.action === "login") {
await authState.login(email, password);
} else {
await authState.register(email, password);
}
} catch (error) {
errorMessage = error.message;
} finally {
isLoading = false;
}
};
</script>
<h2>
{data.action === "login" ? "Login" : "Register"}
</h2>
{#if message}
<div>
<p>{message}</p>
</div>
{/if}
{#if errorMessage}
<div>
<p>{errorMessage}</p>
</div>
{/if}
<form onsubmit={handleForm}>
<label>
<span>Email</span>
<input
id="email"
name="email"
type="email"
placeholder="user@example.com"
required
/>
</label>
<br />
<label>
<span>Password</span>
<input
id="password"
name="password"
type="password"
placeholder="Enter your password"
required
/>
</label>
<br />
<button type="submit" disabled={isLoading}>
{isLoading
? "Please wait..."
: data.action === "login"
? "Login"
: "Register"}
</button>
</form>
{#if data.action === "login"}
<p>
Don't have an account? <a href="/auth/register">Register here</a>
</p>
{:else}
<p>
Already have an account? <a href="/auth/login">Login here</a>
</p>
{/if}
Now, you should now be able to register, log in, and access protected resources using Better Auth. The roles should also work as before.
Summary
In summary:
- Authentication libraries like Better Auth provide out-of-the-box solutions for many of the common authentication and authorization tasks that developers would otherwise need to implement from scratch. These libraries handle many security concerns that are easy to get wrong, including password hashing, session management, and token handling.
- Using Better Auth requires setting up the database schema, configuring the library, and updating both server-side and client-side code to use the library’s functions.
- The library can be extended through hooks and plugins to work with custom features like role-based authorization, allowing you to integrate authentication libraries with existing application logic.