Building a Secure Backend with Hono: A Step-by-Step Tutorial

Here at Voltade, we're committed to building robust and secure software that not only perform efficiently but are also developer-friendly. In this tutorial, we'll walk you through how we leveraged Hono, a minimalist web framework, to power our secure backend. We'll dive deep into the code, highlighting tips and best practices to enhance efficiency and type safety using tools like Zod and TypeScript.

Introduction to Hono

Hono is a lightweight, TypeScript-based web framework that excels in performance and developer experience. It provides a simple yet powerful way to build web applications and APIs, making it an excellent choice for modern backend development.

Overview

This post is going to be really code heavy, so buckle up! Here is the file structure we will be using

/project-root
β”‚
β”œβ”€β”€ /server
β”‚   β”œβ”€β”€ /middlewares
β”‚   β”‚   └── auth.ts           # Authentication middleware for validating API keys and JWT tokens
β”‚   β”‚
β”‚   β”œβ”€β”€ /routes
β”‚   β”‚   β”œβ”€β”€ user.ts           # User-related routes (e.g., create user, login)
β”‚   β”‚   └── auth.ts           # Authentication-related routes (e.g., login)
β”‚   β”‚
β”‚   β”œβ”€β”€ /zod
β”‚   β”‚   β”œβ”€β”€ jwt.ts            # Zod schemas for jwt 
β”‚   β”‚   └── env.ts            # Environment variable validation schema
β”‚   β”‚
β”‚   β”‚
β”‚   β”œβ”€β”€ factory.ts            # Factory to create and configure the Hono app, setting up environment variables and middleware
β”‚   └── index.ts              # Entry point of the server application
β”‚
β”œβ”€β”€ /src
β”‚   β”œβ”€β”€ /lib
β”‚   β”‚   └── api.ts            # API client for making requests from the frontend, including auth headers
β”‚   β”‚
β”‚   β”œβ”€β”€ /routes
β”‚   β”‚   └── ProtectedPage.tsx  # Example frontend route that interacts with the secure backend
β”‚
β”œβ”€β”€ .env                      # Environment variables for the project (e.g., JWT secrets, API keys)
β”œβ”€β”€ package.json              # Project dependencies and scripts
β”œβ”€β”€ tsconfig.json             # TypeScript configuration
└── README.md                 # Project documentation

Setting Up the Environment

Security begins with proper management of environment variables, especially when dealing with sensitive information like database credentials and API keys.

Step 1: Create a .env File

First, create a .env file at the root of your project to store your environment variables securely.

# .env file
SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
SUPABASE_ANON_KEY=your-anonymous-key
SUPABASE_SERVICE_KEY=your-service-key

Step 2: Define Environment Variable Schema with Zod

Using Zod, a TypeScript-first schema declaration and validation library, we can define a schema for our environment variables to ensure type safety and validation.

// server/zod/env.ts
import { z } from 'zod';

export const envSchema = z.object({
  SUPABASE_JWT_SECRET: z.string(),
  SUPABASE_ANON_KEY: z.string(),
  SUPABASE_SERVICE_KEY: z.string(),
});

export type AppEnvVariables = z.infer<typeof envSchema>;

Why Zod? By defining a schema, we can parse and validate the environment variables at runtime, catching errors early and providing type inference throughout our application.

Creating a Factory for Type Safety and Context Management

A factory function helps in creating a consistent application instance with shared context, such as environment variables and middleware.

Step 3: Implement the Factory

// server/factory.ts
import { createFactory } from 'hono/factory';
import { type AppEnvVariables, envSchema } from './zod/env';

export type Variables = Record<string, unknown> & AppEnvVariables;
export const envVariables = envSchema.parse(process.env);

export const factory = createFactory<{ Variables: Variables }>({
  initApp: (app) => {
    app.use(async (c, next) => {
      // Use c.set to add env variables to the context for seamless accessibility
      for (const [key, value] of Object.entries(envVariables)) {
        c.set(key as keyof AppEnvVariables, value);
      }
      await next();
    });
  },
});

Tip: By using c.set, we inject our environment variables into the context, making them easily accessible in our route handlers and middleware without repeatedly importing them.

Tip: By using TypeScript generics in createFactory<{ Variables: Variables }>(), we enhance type safety throughout our application. This ensures that any variables we set in the context, like environment variables, are type-checked and readily available in route handlers and middleware. It reduces runtime errors and improves developer experience by providing autocomplete and type hints in your IDE.

Building the Core Application

With our factory set up, we can now create our application and define routes.

Step 4: Define the Entry Point

// server/index.ts
import { serve } from '@hono/node-server';
import { factory } from './factory';

const app = factory.createApp();

app.get('/', async (c) => {
  // Access env variables directly from context
  const { SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, SUPABASE_SERVICE_KEY } = c.var;
  return c.text('Hello, World!');
});

export const apiRoutes = app.basePath('/api');

export type ApiRoutes = typeof apiRoutes;

const port = 3000;

console.log(`Server is running on port ${port}`);

serve({
  fetch: app.fetch,
  port,
});

Tip: Using c.var, we retrieve our environment variables from the context, ensuring they are readily available and type-safe.

Implementing Secure Authentication Middleware

Security is paramount. We'll create an authentication middleware to validate API keys and JWTs, ensuring only authorized requests access our endpoints.

Step 5: Define the JWT Payload Schema

// server/zod/jwt.ts
import { z } from 'zod';

export const jwtPayloadSchema = z.object({
  sub: z.string(),
  role: z.string(),
  exp: z.number().refine((val) => val > Date.now() / 1000, {
    message: 'expired',
  }),
  iat: z.number(),
});

export type JwtPayload = z.infer<typeof jwtPayloadSchema>;

Tip: Defining the JWT payload schema with Zod allows us to parse and validate the JWT payload, ensuring it contains the expected structure and data types.

Top: The refine method allow us to attain fine grained specificity in validation rules.

Enhancing the Frontend Integration

To communicate securely with our backend, our frontend needs to set the apiKey and authorization headers in each request.

Step 6: Configure the API Client

// src/lib/api.ts
import { hc } from 'hono/client';
import { type ApiRoutes } from 'server/index';

export const api = hc<ApiRoutes>('/', {
  headers: async () => {
    const apiKey = 'your-api-key'; // Retrieve from secure storage or environment
    const accessToken = 'your-access-token'; // Retrieve from authentication flow

    return {
      apiKey,
      authorization: `Bearer ${accessToken}`,
    };
  },
});

// src/components/example.tsx
// here, the authorization headers will be included in the request when 
// we make the api call
export function Example() {
  const { data } = useQuery({
    queryKey: [exmaple],
    queryFn: async () => {
      const res = await api.hello.$get();
      if (!res.ok) {
        throw new Error(res.statusText);
      }
      const data = await res.json();
      return data;
    },
  });
}

Tip: Encapsulating header logic in the API client ensures all requests include the necessary authentication headers without duplicating code.

Step 7: Create the Authentication Middleware

// server/middleware/auth.ts
import { createMiddleware } from 'hono/factory';
import { verify } from 'hono/jwt';
import { HTTPException } from 'hono/http-exception';
import { type Variables as AppEnvVariables, envVariables } from '../factory';
import { jwtPayloadSchema } from '../zod/jwt';

export type Variables = AppEnvVariables & {
  jwtPayload: JwtPayload;
};

interface Options {
  requireServiceRole?: boolean;
}

function validateApiKey(apiKey: string) {
  // Implement your API key validation logic
  return apiKey === envVariables.SUPABASE_ANON_KEY;
}

export function auth({ requireServiceRole = false }: Options = {}) {
  return createMiddleware<{ Variables: Variables }>(async (c, next) => {
    const apiKey = c.req.header('apiKey');
    if (!apiKey || !validateApiKey(apiKey)) {
      throw new HTTPException(401, {
        res: Response.json({ error: 'Unauthorized' }, { status: 401 }),
      });
    }

    const jwtToken = c.req.header('authorization')?.replace('Bearer ', '') as string;

    let jwtPayload: JwtPayload;
    try {
      jwtPayload = jwtPayloadSchema.parse(
        await verify(jwtToken, envVariables.SUPABASE_JWT_SECRET)
      );
    } catch (error) {
      console.error(error);
      throw new HTTPException(401, {
        res: Response.json({ error: 'Unauthorized' }, { status: 401 }),
      });
    }

    // Use without the refine method, we would have to manually validate 
    // the expiry here in our middleware
    const currentTime = Math.floor(Date.now() / 1000);
    if (jwtPayload.exp < currentTime) {
      throw new HTTPException(401, {
        res: Response.json({ error: 'Token expired' }, { status: 401 }),
      });
    }

    if (requireServiceRole && jwtPayload.role !== 'service') {
      throw new HTTPException(403, {
        res: Response.json({ error: 'Forbidden' }, { status: 403 }),
      });
    }

    c.set('jwtPayload', jwtPayload);
    await next();
  });
}

Highlights:

  • Since we ensured that all our requests from our frontend include the necessary authentication headers, we can extract them here in this middleware for further authorization.
  • Type Safety with Generics: By specifying <{ Variables: Variables }> in createMiddleware, we ensure our middleware has access to the typed variables, enhancing type safety.
  • JWT Verification: Using verify from hono/jwt, we verify the JWT token using the secret from our environment variables.
  • Context Injection: We store the verified jwtPayload in the context for use in downstream handlers.

Step 8: Apply the Middleware to Routes

// server/index.ts (continued)
import { auth } from './middleware/auth';

app.get('/protected', auth(), (c) => {
  const jwtPayload = c.get('jwtPayload');
  return c.json({ message: 'You have access', user: jwtPayload.sub });
});

Tip: By applying auth() middleware to the route, we protect it from unauthorized access, and we can access the jwtPayload directly from the context.

Step 9: Make Authenticated Requests

// src/routes/ProtectedPage.tsx
import { api } from '../lib/api';

async function fetchProtectedData() {
  try {
    const response = await api.$get('/protected');
    console.log(response);
  } catch (error) {
    console.error('Error fetching protected data:', error);
  }
}

fetchProtectedData();

Tip: Always handle errors gracefully, especially when dealing with authentication, to provide a better user experience.

Leveraging Type Safety and Inference with Zod and TypeScript

Using Zod and TypeScript together enhances our application's robustness by catching errors at compile-time and ensuring data conforms to expected structures.

Step 10: Define Request Schemas Using Zod and zValidator

In this step, we'll demonstrate how to define request schemas using Zod and integrate them with the zValidator middleware from Hono. This combination ensures that incoming requests are validated against predefined schemas before reaching your route handlers, enhancing security and robustness.

Let's consider a simple example: creating a user registration endpoint.

Define the Schema with Zod

// server/zod/schemas.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(1),
});

Explanation:

  • email: Must be a valid email string.
  • password: Must be a string with a minimum length of 8 characters.
  • name: Must be a non-empty string.

By defining this schema, we ensure that any data passing through our application adheres to these constraints, catching invalid data early in the request lifecycle.

Step 11: Use Schemas in Route Handlers with zValidator

Now, we'll use the zValidator middleware to validate incoming requests against our schema before they reach the route handler.

Implement the Route with Validation

// server/routes/user.ts
import { factory } from '../factory';
import { zValidator } from '@hono/zod-validator';
import { createUserSchema } from '../zod/schemas';

export const route = factory.createApp();

route.post(
  '/users',
  auth(), 
  zValidator('json', createUserSchema), // Validate request body
  async (c) => {
    // Access the validated data
    const userData = c.req.valid('json');

    // Proceed with creating the user (e.g., insert into database)
    // For demonstration purposes, we'll return the user data
    return c.json({
      message: 'User created successfully',
      user: userData,
    });
  }
);

Tip: Always define schemas for your requests and use validation middleware like zValidator to ensure data integrity. This practice not only improves security but also makes your application more maintainable and easier to understand.


Creating Custom Middleware

Middleware in Hono is a powerful way to add functionality across routes.

Step 12: Create a Logging Middleware

// server/middleware/logger.ts
import { MiddlewareHandler } from 'hono';

export const logger: MiddlewareHandler = async (c, next) => {
  const start = Date.now();
  await next();
  const duration = Date.now() - start;
  console.log(`${c.req.method} ${c.req.url} - ${duration}ms`);
};

Step 13: Apply the Middleware Globally

// server/index.ts (continued)
import { logger } from './middleware/logger';

app.use('*', logger);

Tip: Applying middleware to the '*' path ensures it runs for all routes.

Conclusion

  • Efficient Context Management: Using c.set and c.var for seamless access to context variables.
  • Type Safety and Inference: Through generics and schemas, ensuring our application is robust and reliable.
  • JWT Handling and Verification: Securely authenticating users and protecting routes.
  • Creating and Applying Middleware: Enhancing our application with reusable functionality.
  • Frontend Integration: Setting headers for API key and access token to maintain secure communication.

Thank you for joining us on this journey. If you have any questions or suggestions, feel free to reach out. Happy coding!

By following this tutorial, we've built a secure backend powered by Hono, leveraging TypeScript and Zod for type safety and validation. We've explored:

Β