Fullstack authentication with React, Node, Hono, React Hook Forms, Zod and React Query

Table of content

Introduction

In the latest articles we have seen how to do Login/Register forms in the Frontend and using Zod with React Hook Forms separately, in this article I want to hyperfocus on authentication in a full-stack application using email and password, involving several key concepts and steps, particularly when utilizing a stack that includes React, React Hook Form, Hono, and Drizzle.

Concepts

Authentication in a full-stack application using email and password involves several key concepts and steps, particularly when utilizing a stack that includes React, React Hook Form, Hono, and Drizzle. Below is a detailed explanation of the authentication process along with the underlying concepts.

Key Concepts of Authentication

Authentication vs. Authorization:

User Credentials:

Hashing Passwords:

JSON Web Tokens (JWT):

Cookies

When to use Cookies

When to Use JWT

Steps for Implementing Authentication

Backend setup

We define our DB:

import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate as migrator } from "drizzle-orm/bun-sqlite/migrator";
import { Database } from "bun:sqlite";
import { join } from "node:path";
import * as schema from "./schema";

const sqlite = new Database("sqlite.db");

// Enable WAL mode
sqlite.exec("PRAGMA journal_mode = WAL;");

export const db = drizzle(sqlite, { schema, logger: true });

// We use this to migrate using a script
export function migrate() {
  migrator(db, { migrationsFolder: join(import.meta.dirname, "migrations") });
}

We define our DB schema that uses Drizzle and SQLite:

import {
  customType,
  integer,
  primaryKey,
  sqliteTable,
  text,
} from "drizzle-orm/sqlite-core";
import cuid from "cuid";

export const users = sqliteTable("user", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => cuid()),
  username: text("username").notNull().unique(),
  password: text("password").notNull(),
  email: text("email").notNull().unique(),
});

export type DatabaseUserType = InferSelectModel<typeof users>;

We define our userSchema:

import { z } from "zod";

export const userSchema = z.object({
  username: z
    .string()
    .min(3, "Username must be at least 3 characters long")
    .max(20, "Username must not exceed 20 characters")
    .regex(
      /^[a-zA-Z0-9_]+$/,
      "Username can only contain letters, numbers, and underscores"
    )
    .optional(),

  email: z.string().email("Invalid email address"), // Validates email format

  password: z
    .string()
    .min(8, "Password must be at least 8 characters long")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
    .regex(/[a-z]/, "Password must contain at least one lowercase letter")
    .regex(/[0-9]/, "Password must contain at least one number")
    .regex(/[\W_]/, "Password must contain at least one special character"),
});

// Type inference for TypeScript
export type User = z.infer<typeof userSchema>;

This is a great way to validate our inputs, the loop would be to define our schema with zod and then using z.infer<typeof userSchema> to obtain the type.

Next we create the route:

const router = new Hono();

/**
 * @api     POST /register
 * @desc    Register user
 * @access  Public
 */
router.post("/register", zValidator("json", userSchema), async (c) => {
  const { username, password, email } = c.req.valid("json");
  const hashedPassword = await Bun.password.hash(password, "argon2id");

  const existingEmail = await db.query.users.findFirst({
    where(fields, { eq }) {
      return eq(fields.email, email);
    },
  });

  if (existingEmail) {
    return c.body("Email is already on use", HttpStatusCode.NotFound);
  }

  try {
    await db
      .insert(users)
      .values({ username: username ?? email, password: hashedPassword, email });
    return c.body("User created successfully", 200);
  } catch (err) {
    console.error(err);
    const error = err as Error;
    return c.body(error.message, 500);
  }
});

We implement a login endpoint that verifies user credentials by comparing the provided password with the stored hashed password.

/**
 * @api     POST /login
 * @desc    Login user
 * @access  Public
 */
router.post("/login", zValidator("json", userSchema), async (c) => {
  const { password, email } = c.req.valid("json");
  const existingUser = await db.query.users.findFirst({
    where(fields, { eq }) {
      return eq(fields.email, email);
    },
  });

  if (!existingUser) {
    return c.body("User input is not valid", HttpStatusCode.NotFound);
  }

  const passwordMatch = await Bun.password.verify(
    password,
    existingUser.password,
    "argon2id"
  );

  if (!passwordMatch) {
    return c.body("User input is not valid", HttpStatusCode.NotFound);
  }

  setCookie(c, "session", existingUser.id, {
    path: "/",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    maxAge: 86400,
  });

  return c.json(
    { username: existingUser.username, email, id: existingUser.id },
    200
  );
});

Next we need an endpoint that lets us know if our credentials are still valid:

import { getCookie } from "hono/cookie";
/**
 * @api     GET /me
 * @desc    Retrieves current user data
 * @access  Private
 */
router.get("/me", async (c) => {
  const userId = getCookie(c, "session");

  if (!userId) {
    return c.body("User not found", 404);
  }

  const existingUser = await db.query.users.findFirst({
    where(fields, { eq }) {
      return eq(fields.id, userId);
    },
  });

  if (!existingUser) {
    return c.body("User already exists", 404);
  }

  return c.json({
    username: existingUser.username,
    sessionId: existingUser.id,
    email: existingUser.email,
  });
});

And finally we need to be able to delete credentials:

/**
 * @api     POST /logout
 * @desc    Logout user
 * @access  Public
 */
router.post("/signout", async (c) => {
  // Get the session cookie
  const sessionCookie = getCookie(c, "session");

  if (!sessionCookie) {
    return c.body("No session found", 404);
  }

  // Delete the session cookie
  deleteCookie(c, "session");

  // Return a success response
  return c.json({ message: "Logged out successfully" }, 200);
});

export default router;

Frontend setup

We define our API client with ky:

import { env } from "@/lib/env";
import ky from "ky";

export const api = ky.extend({
  prefixUrl: env.API_URL,
  credentials: "include",
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
  },
  // TO-DO handle error response via afterResponse
});

We define our services:

For the register:

import { api } from "@/lib/client";

/**
 *
 * @param username
 * @param password
 * @param email
 * @returns
 */
export async function createAccount(
  username: string,
  password: string,
  email: string
) {
  const response = await api.post("register", {
    json: { username, password, email },
  });

  if (response.status !== 200) {
    throw new Error("Could not create account");
  }

  return response;
}

For the login:

import { api } from "@/lib/client";

/**
 *
 * @param email
 * @param password
 */
export async function createSession(email: string, password: string) {
  try {
    const response = await api.post("signin", {
      json: { email, password },
    });

    // Check for successful response status
    if (response.status !== 200) {
      throw new Error(`Unexpected response status: ${response.status}`);
    }
  } catch (error) {
    // Handle specific error types if necessary
    if (error instanceof HTTPError) {
      const errorMessage = await error.response.text();
      throw new Error(`HTTP error: ${errorMessage}`);
    } else {
      // Re-throw other errors
      throw new Error(`Could not create session`);
    }
  }
}

To get current user session:

// A helper I made for GET endpoints, feel free to use it normally instead
export async function fetchData<T>(endpoint: string): Promise<T> {
  const response = await api.get(endpoint);
  return await response.json<T>();
}

/**
 *
 * Get current user session
 */
export async function getCurrentUser(): Promise<CurrentUser | null> {
  const data = await fetchData("me");
  const result = currentUserSchema.safeParse(data);
  if (result.success) return result.data;
  return null;
}
export function useMeQuery() {
  return useQuery({
    queryKey: ["users/me"],
    queryFn: getCurrentUser,
  });
}
export function useCurrentUser() {
  const { data } = useMeQuery();
  console.log({ data });
  return data;
}

Here we use React Query to cache the session.

We create the components

For the login and register:

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UserCredentials, formSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";

export type Props = Readonly<{
  type: FormType,
  onSubmit: (value: UserCredentials) => void,
}>;

export function AuthForm({ onSubmit, type }: Props) {
  const form =
    useForm <
    UserCredentials >
    {
      resolver: zodResolver(formSchema),
      defaultValues: {
        email: "",
        password: "",
      },
    };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <fieldset className="flex flex-col justify-center items-center">
          <Card className="w-full max-w-sm">
            <CardHeader>
              <CardTitle className="text-2xl">{type}</CardTitle>
              <CardDescription>
                Enter your email and password below to {type}
              </CardDescription>
            </CardHeader>

            <CardContent className="grid gap-4">
              {type === "register" && (
                <FormField
                  control={form.control}
                  name="username"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Username</FormLabel>
                      <FormControl>
                        <Input
                          type="text"
                          placeholder="enter your username"
                          {...field}
                        />
                      </FormControl>
                      <FormDescription>
                        Feel free to use the name you want
                      </FormDescription>
                      <FormMessage />
                    </FormItem>
                  )}
                />
              )}
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Email</FormLabel>
                    <FormControl>
                      <Input
                        type="email"
                        placeholder="enter your email"
                        {...field}
                      />
                    </FormControl>
                    <FormDescription>
                      We wont share your email with anybody.
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />

              <FormField
                control={form.control}
                name="password"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Password</FormLabel>
                    <FormControl>
                      <Input
                        type="password"
                        placeholder="enter your password"
                        {...field}
                      />
                    </FormControl>
                    <FormDescription>
                      Try to make it as secure as possible.
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </CardContent>
            <CardFooter>
              <Button type="submit" className="w-full">
                {type}
              </Button>
            </CardFooter>
          </Card>
        </fieldset>
      </form>
    </Form>
  );
}

In the login route:

export default function LoginRoute() {
  const navigate = useNavigate();

  function onSubmit(values: UserCredentials) {
    const op = createSession(values.email, values.password);
    console.log({ values });
    toast.promise(op, {
      success: () => {
        navigate("/app");
        return "You successfully logged in";
      },
      error: (error: unknown) => {
        console.log({ error });
        return "Something went wrong while authenticating";
      },
      loading: "Authenticating...",
    });
  }
  return (
    <div className="flex flex-col items-center gap-4">
      <AuthForm onSubmit={onSubmit} type="login" />
      <Link className="underline text-xs" to="/register">
        Click to register account
      </Link>
    </div>
  );
}

In the register route:

export default function Register() {
  const navigate = useNavigate();

  function onSubmit(values: UserCredentials) {
    const op = createAccount(values.username, values.email, values.password);
    console.log({ values });
    toast.promise(op, {
      success: () => {
        navigate("/login");
        return "You successfully register";
      },
      error: (error: unknown) => {
        console.log({ error });
        return "Something went wrong while registering";
      },
      loading: "Registering...",
    });
  }
  return (
    <div className="flex flex-col items-center gap-4">
      <AuthForm onSubmit={onSubmit} type="register" />
      <Link className="underline text-xs" to="/register">
        Click to login
      </Link>
    </div>
  );
}

And that’s pretty much it! You need to add the providers to your App:

export default function App() {
  return (
    <Suspense
      fallback={
        <div className="flex h-screen w-screen items-center justify-center">
          <Loader size="xl" />
        </div>
      }
    >
      <ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
        <Toaster />
        <QueryClientProvider client={queryClient}>
          <Router>
            <AuthProvider>
              <Routes>
                <Route path="/" element={<Landing />}></Route>
                <Route path="/login" element={<Login />}></Route>
                <Route path="/register" element={<Register />}></Route>
                <Route path="/app" element={<DashboardLayout />}>
                  <Route path="" element={<HomeRoute />} />
                  <Route path="profile" element={<ProfileRoute />} />
                </Route>
              </Routes>
            </AuthProvider>
          </Router>
        </QueryClientProvider>
      </ErrorBoundary>
    </Suspense>
  );
}

Conclusion

We have learned how to do a real world Authentication, with practice it can take you 20 minutes to do from backend to frontend and its an important pattern to learn in terms of speed since its what interviews usually ask of you. We will handle similar projects in the future.

See you on the next post.

Sincerely,

Eng. Adrian Beria