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…