Skip to content
Back to skills

Hapi

ASecurity

hapi is a Node.js web framework for building HTTP APIs from configuration: routes declare their validation, authentication, caching and CORS rules, and the framework enforces them before the handler runs. Use when a user asks to build or fix a hapi server, validate request payloads or responses with Joi, protect routes with JWT through @hapi/jwt, split an API into hapi plugins, cache results with server methods, hook into the request lifecycle, return errors with @hapi/boom, or test routes wi...

  • 142 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added May 29, 2026
developmentjavascripttypescriptgojavabashnodeexpressgitapi

Works with

  • terminal
  • cli
  • api

Security analysis

A92/100
  • mediumUses curl or wget to download content
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 2 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add TerminalSkills/skills --skill hapi --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hapi?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hapi
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/terminalskills-hapi/badge)](https://www.skillsdirectory.com/skills/terminalskills-hapi)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hapi
description: >-
  hapi is a Node.js web framework for building HTTP APIs from configuration:
  routes declare their validation, authentication, caching and CORS rules, and
  the framework enforces them before the handler runs. Use when a user asks to
  build or fix a hapi server, validate request payloads or responses with Joi,
  protect routes with JWT through @hapi/jwt, split an API into hapi plugins,
  cache results with server methods, hook into the request lifecycle, return
  errors with @hapi/boom, or test routes with server.inject().
license: Apache-2.0
compatibility: 'Node.js 14.15+ for @hapi/hapi 21; joi 18 requires Node.js 20+'
metadata:
  author: terminal-skills
  version: 1.1.0
  category: development
  repository: https://github.com/hapijs/hapi
  tags:
    - node
    - web-framework
    - api
    - validation
    - authentication
---

# Hapi — Enterprise Node.js Framework

## Overview

hapi (`@hapi/hapi`, currently 21.x) is a configuration-centric HTTP framework for Node.js. A route is an object that declares its input validation, response schema, authentication and access rules; hapi runs those steps in a fixed request lifecycle and only then calls the handler. Validation uses Joi schemas (the separate `joi` package), authentication is added through scheme plugins such as `@hapi/jwt`, and application code is organised into plugins. There is no middleware chain: cross-cutting logic goes into lifecycle extension points. Rate limiting is not built in.

## Instructions

### Installation

```bash
npm install @hapi/hapi joi @hapi/jwt @hapi/boom
# Optional: static files and template rendering
npm install @hapi/inert @hapi/vision
```

Install `joi`, not `@hapi/joi` — the scoped package is deprecated on npm ("Switch to 'npm install joi'"). TypeScript declarations ship inside `@hapi/hapi`, `@hapi/jwt`, `@hapi/boom` and `joi`; no `@types/*` packages are needed.

### Server, authentication and routes

Register the auth scheme and strategy before adding any route that names it; otherwise `server.route()` throws `Unknown authentication strategy jwt in /api/users`.

```javascript
// server.mjs
import Hapi from "@hapi/hapi";
import Jwt from "@hapi/jwt";
import Boom from "@hapi/boom";
import Joi from "joi";
import { randomUUID } from "node:crypto";

const users = new Map();

export async function createServer() {
  const server = Hapi.server({
    port: Number(process.env.PORT ?? 3000),
    host: "0.0.0.0",
    routes: {
      cors: { origin: ["https://app.northwind.dev"], credentials: true },
      validate: {
        options: { abortEarly: false },
        // Default reply is the generic "Invalid request payload input";
        // rethrowing exposes Joi's message and the failing keys to the client.
        failAction: (request, h, err) => { throw err; },
      },
    },
  });

  await server.register(Jwt);
  server.auth.strategy("jwt", "jwt", {
    keys: process.env.JWT_SECRET,
    // aud, iss and sub are required unless verify is false; false skips one check
    verify: { aud: "urn:audience:northwind-api", iss: "urn:issuer:northwind-auth", sub: false, maxAgeSec: 14400, timeSkewSec: 15 },
    validate: (artifacts, request, h) => ({
      isValid: true,
      credentials: { user: artifacts.decoded.payload.user, scope: artifacts.decoded.payload.scope },
    }),
  });
  server.auth.default("jwt"); // every route now requires a token unless it opts out

  server.route({
    method: "POST",
    path: "/api/users",
    options: {
      auth: { access: { scope: ["admin"] } }, // checked against credentials.scope
      validate: {
        payload: Joi.object({
          name: Joi.string().min(2).max(100).required(),
          email: Joi.string().email().required(),
          role: Joi.string().valid("user", "admin").default("user"),
        }),
      },
      response: {
        schema: Joi.object({
          id: Joi.string().uuid().required(),
          name: Joi.string().required(),
          email: Joi.string().email().required(),
          role: Joi.string().valid("user", "admin").required(),
          createdAt: Joi.date().required(),
        }),
      },
    },
    handler: (request, h) => {
      const user = { id: randomUUID(), ...request.payload, createdAt: new Date() };
      users.set(user.id, user);
      return h.response(user).code(201);
    },
  });

  server.route({
    method: "GET",
    path: "/api/users/{id}",
    options: { validate: { params: Joi.object({ id: Joi.string().uuid().required() }) } },
    handler: (request) => {
      const user = users.get(request.params.id);
      if (!user) throw Boom.notFound("User not found");
      return user; // objects are serialised as JSON with status 200
    },
  });

  server.route({ method: "GET", path: "/health", options: { auth: false }, handler: () => ({ status: "ok" }) });
  return server;
}

if (process.argv[2] === "start") {
  const server = await createServer();
  await server.start();
  console.log(`Listening on ${server.info.uri}`);
}
```

Validation rules must be compiled Joi schemas (`Joi.object({...})`). A plain object such as `{ limit: Joi.number() }` fails with `Cannot set uncompiled validation rules without configuring a validator` unless `server.validator(Joi)` was called first.

### Plugins, server methods and lifecycle extensions

A plugin is an object with `name` and an async `register(server, options)`. Server methods are shared functions with optional caching (in-memory by default; `generateTimeout` is required when `cache` is set). `server.ext()` attaches to lifecycle points, which run in this order: `onRequest`, `onPreAuth`, `onCredentials`, `onPostAuth`, `onPreHandler`, `onPostHandler`, `onPreResponse`, `onPostResponse`.

```javascript
// invoices.mjs
export const invoicesPlugin = {
  name: "invoices",
  version: "1.0.0",
  register: async (server, options) => {
    server.method("invoiceTotal", async (customerId) => {
      return { customerId, total: 1280.5, currency: options.currency };
    }, { cache: { expiresIn: 60_000, generateTimeout: 2_000 } });

    server.ext("onPreResponse", (request, h) => {
      const response = request.response;
      // Error responses are Boom objects and have no header() method
      if (!response.isBoom) response.header("x-response-time", `${Date.now() - request.info.received}ms`);
      return h.continue;
    });

    server.route({
      method: "GET",
      path: "/customers/{customerId}/total",
      options: { auth: false },
      handler: (request) => server.methods.invoiceTotal(request.params.customerId),
    });
  },
};
```

```javascript
// Registration options and a path prefix for every route the plugin adds
await server.register({
  plugin: invoicesPlugin,
  options: { currency: "EUR" },
  routes: { prefix: "/api/invoices" },
});
```

## Examples

### Example 1: Run the API and call it

**User request:** "Start the users API locally and show me what an unauthenticated call and the health check return."

```bash
export JWT_SECRET="$(openssl rand -base64 32)"
PORT=3000 node server.mjs start
# Listening on http://0.0.0.0:3000

# In a second terminal (the server stays in the foreground):
curl -s http://127.0.0.1:3000/health
# {"status":"ok"}

curl -s -X POST http://127.0.0.1:3000/api/users \
  -H 'content-type: application/json' \
  -d '{"name":"Maria Keller","email":"maria.keller@northwind.dev"}'
# {"statusCode":401,"error":"Unauthorized","message":"Missing authentication"}
```

Authentication runs before payload validation, so a request without a token gets 401 even when its body is invalid. A token whose `scope` lacks `admin` gets `{"statusCode":403,"error":"Forbidden","message":"Insufficient scope"}`.

### Example 2: Test routes without opening a port

**User request:** "Write tests for POST /api/users that cover a missing token, a bad payload and a successful create."

```javascript
// users.test.mjs — run with: node --test users.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import Jwt from "@hapi/jwt";
import { createServer } from "./server.mjs";

process.env.JWT_SECRET = randomBytes(32).toString("base64"); // throwaway key for this test run

test("POST /api/users", async (t) => {
  const server = await createServer();
  await server.initialize(); // finalises plugins and caches, does not listen

  await t.test("rejects a missing token", async () => {
    const res = await server.inject({ method: "POST", url: "/api/users", payload: { name: "Maria Keller", email: "maria.keller@northwind.dev" } });
    assert.equal(res.statusCode, 401);
  });

  await t.test("rejects a bad payload", async () => {
    const res = await server.inject({
      method: "POST", url: "/api/users",
      auth: { strategy: "jwt", credentials: { user: "ci", scope: ["admin"] } }, // bypasses token parsing
      payload: { name: "M", email: "not-an-email" },
    });
    assert.equal(res.statusCode, 400);
    assert.deepEqual(res.result.validation.keys, ["name", "email"]);
  });

  await t.test("creates a user with a real token", async () => {
    const token = Jwt.token.generate(
      { aud: "urn:audience:northwind-api", iss: "urn:issuer:northwind-auth", user: "maria.keller", scope: ["admin"] },
      { key: process.env.JWT_SECRET, algorithm: "HS256" },
      { ttlSec: 3600 },
    );
    const res = await server.inject({
      method: "POST", url: "/api/users",
      headers: { authorization: `Bearer ${token}` },
      payload: { name: "Maria Keller", email: "maria.keller@northwind.dev" },
    });
    assert.equal(res.statusCode, 201);
    assert.equal(res.result.role, "user"); // Joi default applied
  });

  await server.stop();
});
```

Result: `tests 4, pass 4, fail 0`. The bad-payload reply body is `{"statusCode":400,"error":"Bad Request","message":"\"name\" length must be at least 2 characters long. \"email\" must be a valid email","validation":{"source":"payload","keys":["name","email"]}}`.

## Guidelines

1. **Validate every input at the route** — `validate.params`, `query`, `payload`, `headers` and `state` reject bad input with 400 before the handler runs. Joi defaults and conversions are written back to `request.payload`, `request.query` and `request.params`.
2. **Decide how much validation detail to expose** — by default clients see only `Invalid request payload input`. A `failAction` that rethrows the error returns field-level messages; log instead of rethrowing if those messages would leak internals.
3. **Response validation fails closed** — a handler result that breaks `response.schema` becomes a 500. Use `response.failAction: 'log'` or `response.sample` (percentage of responses checked) in production if a 500 is too strict.
4. **Never combine `origin: ['*']` with `credentials: true`** — hapi then echoes any caller's `Origin` back with `Access-Control-Allow-Credentials: true`. List the allowed origins explicitly. CORS is off by default.
5. **Keep secrets in the environment** — pass `process.env.JWT_SECRET` (or a JWKS `{ uri }`) as `keys`; do not use `algorithms: ['none']` or `verify: false` outside local experiments.
6. **Plugins for modularity** — group routes, methods and extensions per feature; pass configuration through `options` and mount with `routes.prefix`. Registering the same plugin twice throws unless `once` or `multiple` is set.
7. **Errors through `@hapi/boom`** — `throw Boom.notFound()`, `Boom.badRequest()`, `Boom.forbidden()` produce the same `{ statusCode, error, message }` body hapi uses. Uncaught exceptions become a 500 with a generic message.
8. **Test with `server.inject()`** — call `server.initialize()` instead of `server.start()` in tests. The `auth` option injects credentials directly, so cover real token parsing with at least one request that sends an `Authorization` header.
9. **When not to use hapi** — it targets long-running Node.js servers. For edge runtimes or apps built around Express or Koa middleware, pick a framework designed for those.

Files in this skill

  • SKILL.md3.9 KB
  • _scores.json1.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.