DEV Community

Cover image for Building Modern Backends with Kaapi: Authorization
ShyGyver
ShyGyver

Posted on

Building Modern Backends with Kaapi: Authorization

Kaapi: A flexible, extensible backend framework for modern APIs with messaging, documentation, and type safety built right in.

This series is written for backend developers who love TypeScript and can appreciate Hapi’s design philosophy.


Authorization?

I hesitated for a long time before writing this article.

Not because authentication and authorization are boring, quite the opposite. The problem is that they’re big. There are too many angles, too many trade-offs, and too many "it depends" moments to squeeze everything into a single post without turning it into a textbook.

At some point I stopped overthinking it and decided to do what backend engineers do best: split the problem into smaller pieces.

This series will grow over time. Eventually, we’ll get into Kaapi’s AuthDesign plugins, build a real authentication provider, and walk through OAuth2 and OIDC flows. But today, we’ll start simple and focus on the basics and wire up authentication and authorization using good old cookies.

No magic. No black boxes. Just Kaapi, Hapi, and a clear flow you can reason about.


Auth Designs: Kaapi’s way of thinking about auth

In Kaapi, authorization is organized around a concept called an Auth Design.

Think of an Auth Design as a reusable, self-contained authorization module that sits on top of Hapi’s auth system. It doesn’t just validate credentials, it defines how authentication works across your app.

An Auth Design can:

Kaapi ships with several built-in designs (Bearer, Basic, API Key), but the real power comes from being able to extend or replace them.

Enough theory though, let’s start by wiring things up using the built-ins and see how it feels in practice.


Bearer tokens: the “hello world” of APIs

Bearer authentication is usually the first stop. It works well for JWTs, API tokens, and anything that fits neatly into an Authorization header.

Here’s what it looks like in Kaapi.

import { Kaapi, BearerAuthDesign } from '@kaapi/kaapi';

const bearerAuth = new BearerAuthDesign({
  auth: {
    async validate(request, token) {
      if (token !== 'secret') {
        return { isValid: false };
      }

      return {
        isValid: true,
        credentials: { user: { name: 'admin' } },
      };
    },
  },
});

const app = new Kaapi({
  port: 3000,
  host: 'localhost',
  extend: [bearerAuth],
  routes: {
    auth: {
      strategy: bearerAuth.getStrategyName(),
      mode: 'try',
    },
  },
});

app.route(
  {
    method: 'GET',
    path: '/profile',
    auth: true, // shorthand for "mode: required"
  },
  ({ auth: { credentials: { user } } }) => `Hello ${user?.name || 'Guest'}!`
);

await app.listen();
Enter fullscreen mode Exit fullscreen mode

Once this is running, you can hit /profile with:

Authorization: Bearer secret
Enter fullscreen mode Exit fullscreen mode

If the token matches, you’re authenticated. If not, Kaapi quietly falls back to “guest mode”. Simple, explicit, predictable.


Basic auth: boring, but sometimes perfect

Basic authentication isn’t glamorous, but it’s still incredibly useful especially for internal tools, admin panels, or quick prototypes.

Kaapi includes a BasicAuthDesign out of the box, and setting it up feels almost suspiciously straightforward.

import { Kaapi, BasicAuthDesign } from '@kaapi/kaapi';

const basicAuth = new BasicAuthDesign({
  auth: {
    async validate(request, username, password) {
      if (username === 'admin' && password === 'password') {
        return { isValid: true, credentials: { user: { name: username } } };
      }

      return { isValid: false };
    },
  },
});

const app = new Kaapi({
  port: 3000,
  host: 'localhost',
  extend: [basicAuth],
  routes: {
    auth: {
      strategy: basicAuth.getStrategyName(),
      mode: 'try',
    },
  },
});

app.route(
  {
    method: 'GET',
    path: '/profile',
    auth: true,
  },
  ({ auth: { credentials: { user } } }) => `Hello ${user?.name || 'Guest'}!`
);

await app.listen();
Enter fullscreen mode Exit fullscreen mode

Send a standard HTTP Basic header at /profile:

Authorization: Basic YWRtaW46cGFzc3dvcmQ=
Enter fullscreen mode Exit fullscreen mode

No ceremony. No extra wiring. It does exactly what you’d expect.


API keys: headers, queries, cookies

Sometimes you don’t want usernames and passwords. Sometimes you just want a token passed via a header, a query parameter, or a cookie.

That’s where APIKeyAuthDesign comes in.

import { Kaapi, APIKeyAuthDesign } from '@kaapi/kaapi';

const apiKeyAuth = new APIKeyAuthDesign({
  key: 'authorization',
  auth: {
    async validate(request, token) {
      if (token === 'secret') {
        return {
          isValid: true,
          credentials: { user: { name: 'admin' } },
        };
      }
      return { isValid: false };
    },
    headerTokenType: 'Session',
  },
})
  .inHeader() // default
  .setDescription('Session token type');

const app = new Kaapi({
  port: 3000,
  host: 'localhost',
  extend: [apiKeyAuth],
  routes: {
    auth: {
      strategy: apiKeyAuth.getStrategyName(),
      mode: 'try',
    },
  },
});

app.route(
  {
    method: 'GET',
    path: '/profile',
    auth: true,
  },
  ({ auth: { credentials: { user } } }) => `Hello ${user?.name || 'Guest'}!`
);

await app.listen();
Enter fullscreen mode Exit fullscreen mode

By default, it reads from headers, but you can just as easily switch to:

apiKeyAuth.inQuery();
apiKeyAuth.inCookie();
Enter fullscreen mode Exit fullscreen mode

The important detail here is that Kaapi normalizes everything for you. Your validate function receives the token without the prefix, regardless of where it came from.

At this point, we’ve covered the built-ins. Useful, flexible but still generic.

Now let’s do something more interesting.


Rolling our own: a cookie-based session Auth Design

What we want now is a complete flow:

  • A login page
  • Session-based authentication
  • Authorization via cookies
  • A logout endpoint

And we want all of that encapsulated inside a single Auth Design.

To keep things simple, we’ll use:

  • @hapi/yar for session cookies
  • A tiny in-memory user “database”
  • Plain HTML strings instead of a template engine

This isn’t about production hardening, it’s about understanding the architecture.


A tiny fake database

We’ll start with a mocked list of users.

Yes, the password is stored in clear text. No, that’s not the point here. Please forgive me.

export interface User {
  id: string;
  username: string;
  password: string;
  email: string;
}

export const REGISTERED_USERS: User[] = [
  { id: 'user-1', username: 'user', password: 'crossterm', email: 'user@email.com' },
];
Enter fullscreen mode Exit fullscreen mode

Wiring up session cookies with yar

Because we’re relying on cookies, we need to register yar as a Kaapi plugin. This gives us encrypted, HTTP-only session storage with almost no effort.

import yar, { YarOptions } from '@hapi/yar';
import { KaapiPlugin } from '@kaapi/kaapi';

const COOKIE_NAME = 'sid-auth-app';

const yarOptions: YarOptions = {
  name: COOKIE_NAME,
  maxCookieSize: 1024,
  storeBlank: false,
  cookieOptions: {
    password: 'the-password-must-be-at-least-32-characters-long',
    path: '/',
    isSameSite: 'Strict',
    isSecure: process.env.NODE_ENV === 'production',
    isHttpOnly: true,
    clearInvalid: true,
    ignoreErrors: true,
    ttl: 4.32e7, // 12 hours
  },
};

export const yarPlugin: KaapiPlugin = {
  async integrate(t) {
    t.server.register({
      plugin: yar,
      options: yarOptions,
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

Once this plugin is registered, req.yar becomes available everywhere including inside our Auth Design.


HTML without a template engine (on purpose)

To keep the example focused, we’ll generate HTML directly from strings:

  • One page for the login form
  • One page for a successful login

This keeps the moving parts to a minimum and makes the flow easy to follow.

function generateFormHtml(errorMessage?: string): string {
  return `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Sign in</title>
  <style>
    :root {
      --bg: #0f172a;
      --card: #111827;
      --accent: #6366f1;
      --text: #e5e7eb;
      --muted: #9ca3af;
      --ring: rgba(99,102,241,.35);
    }
    * { box-sizing: border-box; }
    body {
      margin: 0; min-height: 100vh; display: grid; place-items: center;
      background: radial-gradient(1200px 600px at 20% 0%, #1f2937, var(--bg));
      font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, sans-serif;
      color: var(--text);
    }
    .card {
      width: 92%; max-width: 380px; padding: 26px 24px; border-radius: 16px;
      background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
      border: 1px solid rgba(255,255,255,.08);
      box-shadow: 0 20px 50px rgba(0,0,0,.35);
      backdrop-filter: blur(8px);
    }
    .error {
      background: rgba(239,68,68,.15);
      color: #f87171;
      border: 1px solid rgba(239,68,68,.4);
      padding: 10px 14px;
      border-radius: 10px;
      font-size: .9rem;
      margin-bottom: 14px;
    }
    .title { font-size: 1.25rem; font-weight: 600; letter-spacing: .2px; margin: 0 0 8px; }
    .subtitle { color: var(--muted); font-size: .95rem; margin: 0 0 18px; }
    label { display: block; font-size: .85rem; color: var(--muted); margin: 12px 0 8px; }
    .field {
      display: flex; align-items: center; gap: 8px;
      background: #0b1220; border: 1px solid rgba(255,255,255,.08);
      padding: 12px 14px; border-radius: 12px;
      transition: border-color .2s, box-shadow .2s, transform .05s;
    }
    .field:focus-within {
      border-color: var(--accent); box-shadow: 0 0 0 4px var(--ring);
    }
    .field input {
      all: unset; flex: 1; color: var(--text); caret-color: var(--accent);
    }
    .icon {
      width: 18px; height: 18px; opacity: .7;
      filter: drop-shadow(0 1px 0 rgba(0,0,0,.35));
    }
    .actions { margin-top: 18px; display: flex; align-items: center; justify-content: space-between; }
    .btn {
      appearance: none; border: none; cursor: pointer;
      background: linear-gradient(135deg, #7c3aed, var(--accent));
      color: white; padding: 12px 16px; border-radius: 12px; font-weight: 600;
      box-shadow: 0 10px 20px rgba(99,102,241,.35); transition: transform .05s, filter .2s;
    }
    .btn:hover { filter: brightness(1.05); }
    .btn:active { transform: translateY(1px); }
    .meta { font-size: .85rem; color: var(--muted); }
    .link { color: var(--text); text-decoration: none; border-bottom: 1px dashed rgba(255,255,255,.2); }
    .link:hover { color: var(--accent); border-bottom-color: var(--accent); }
  </style>
</head>
<body>
  <form class="card" action="/login" method="post">
    <h1 class="title">Welcome back</h1>
    <p class="subtitle">Sign in to continue</p>

    <!-- Error message placeholder --> 

    ${errorMessage
      ? `<p class="error" id="error-message">
        ${errorMessage}
    </p>`
      : ''
    }

    <label for="username">Username</label>
    <div class="field">
      <svg class="icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-4.42 0-8 2.18-8 4.87V21h16v-2.13C20 16.18 16.42 14 12 14Z"/>
      </svg>
      <input id="username" name="username" type="text" placeholder="yourname" required autocomplete="username"/>
    </div>

    <label for="password">Password</label>
    <div class="field">
      <svg class="icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M17 8V7a5 5 0 0 0-10 0v1H5v12h14V8Zm-8 0V7a3 3 0 0 1 6 0v1Z"/>
      </svg>
      <input id="password" name="password" type="password" placeholder="••••••••" required autocomplete="current-password"/>
    </div>

    <div class="actions">
      <button class="btn" type="submit">Sign in</button>
      <span class="meta"><a class="link" href="#">Forgot password?</a></span>
    </div>
  </form>
</body>
</html>
            `;
}

function generateSuccessHtml(): string {
  return `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Login Successful</title>
  <style>
    :root {
      --bg: #0f172a;
      --card: #111827;
      --accent: #22c55e;
      --danger: #ef4444;
      --text: #e5e7eb;
      --muted: #9ca3af;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0; min-height: 100vh; display: grid; place-items: center;
      background: radial-gradient(1200px 600px at 20% 0%, #1f2937, var(--bg));
      font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, sans-serif;
      color: var(--text);
    }
    .card {
      width: 92%; max-width: 420px; padding: 32px 28px; border-radius: 18px;
      background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
      border: 1px solid rgba(255,255,255,.08);
      box-shadow: 0 20px 50px rgba(0,0,0,.35);
      backdrop-filter: blur(8px);
      text-align: center;
    }
    .icon {
      width: 64px; height: 64px; margin-bottom: 20px;
      color: var(--accent);
      filter: drop-shadow(0 4px 6px rgba(34,197,94,.35));
    }
    h1 { font-size: 1.6rem; margin: 0 0 12px; }
    p { font-size: 1rem; color: var(--muted); margin: 0 0 24px; }
    .btn {
      appearance: none; border: none; cursor: pointer;
      padding: 14px 20px; border-radius: 12px; font-weight: 600;
      box-shadow: 0 10px 20px rgba(0,0,0,.25); transition: transform .05s, filter .2s;
      margin: 6px;
    }
    .btn:hover { filter: brightness(1.05); }
    .btn:active { transform: translateY(1px); }
    .btn-success {
      background: linear-gradient(135deg, #16a34a, var(--accent));
      color: white;
      box-shadow: 0 10px 20px rgba(34,197,94,.35);
    }
    .btn-danger {
      background: linear-gradient(135deg, #dc2626, var(--danger));
      color: white;
      box-shadow: 0 10px 20px rgba(239,68,68,.35);
    }
  </style>
</head>
<body>
  <div class="card">
    <!-- Success checkmark icon -->
    <svg class="icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-1 15-5-5 1.41-1.41L11 14.17l6.59-6.59L19 9l-8 8Z"/>
    </svg>
    <h1>Login Successful 🎉</h1>
    <p>Welcome back! You’ve successfully signed in.</p>
    <div>
      <button class="btn btn-success" onclick="window.location.href='/docs/api'">Go to Dashboard</button>
      <button class="btn btn-danger" onclick="window.location.href='/logout'">Log Out</button>
    </div>
  </div>
</body>
</html>
`;
}
Enter fullscreen mode Exit fullscreen mode

Building the Auth Design itself

Now for the interesting part.

Instead of starting from scratch, we extend Kaapi’s APIKeyAuthDesign. Why? Because it already knows how to read credentials from cookies and we don’t need to reinvent that wheel.

Our custom design will:

  • Force authentication to happen only via cookies
  • Disable OpenAPI documentation (this is an internal mechanism)
  • Register /login and /logout routes as part of the design itself
import yar from '@hapi/yar';
import { APIKeyAuthArg, APIKeyAuthDesign, KaapiTools } from '@kaapi/kaapi';

export class CookieSessionAuthDesign extends APIKeyAuthDesign {
  constructor(settings: APIKeyAuthArg) {
    super(settings);
    this.inCookie();
  }

  /**
   * @override
   * This override disables OpenAPI generation for this security scheme.
   */
  docs() {
    return undefined;
  }

  /**
   * @override
   * This method has no effect for this subclass.
   */
  inHeader(): this {
    return this;
  }

  /**
   * @override
   * This method has no effect for this subclass.
   */
  inQuery(): this {
    return this;
  }

  async integrateHook(t: KaapiTools): Promise<void> {
    await super.integrateHook(t);

    t.route({
      path: '/logout',
      method: 'GET',
      options: {
        cors: false,
        plugins: {
          kaapi: {
            docs: false,
          },
        },
      },
      handler: (req, h) => {
        req.yar.reset();

        return h.redirect('/login');
      },
    });

    t.route<{ Payload: { username?: string; password?: string }; AuthUser: { id: string } }>({
      path: '/login',
      method: ['GET', 'POST'],
      options: {
        auth: {
          mode: 'try',
          strategy: this.strategyName,
        },
        cors: false,
        plugins: {
          kaapi: {
            docs: false,
          },
        },
      },
      handler: (req, h) => {
        let errorMessage = '';
        let statusCode = 200;

        if (req.auth.credentials?.user?.id) {
          return generateSuccessHtml();
        }

        if (req.method.toUpperCase() === 'POST') {
          const user = REGISTERED_USERS.find((u) => u.username === req.payload.username);

          if (user && user.password === req.payload.password) {
            req.yar.set('userId', user.id);
            return generateSuccessHtml();
          } else {
            statusCode = 401;
            errorMessage = 'Invalid username or password';
          }
        }

        return h.response(generateFormHtml(errorMessage)).code(statusCode);
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

A couple of things are worth calling out here:

  • We override docs() to prevent this auth scheme from leaking into public API documentation
  • We explicitly disable headers and query tokens ➜ cookies only
  • Login and logout live inside the Auth Design, not scattered across the app

This is the key idea: the entire authentication lifecycle is owned by the design.


Validating the session

Finally, we instantiate the design and define how session validation works.

On every request:

  • We read the session cookie via yar
  • We look up the user
  • If it exists, we authenticate and attach credentials
export const cookieSessionAuth = new CookieSessionAuthDesign({
  strategyName: 'cookie-session',
  key: COOKIE_NAME,
  auth: {
    /**
     *
     * @param req
     * @param _token cookie value
     * @returns
     */
    async validate(req, _token) {
      const value = req.yar.get('userId');

      const user = REGISTERED_USERS.find((u) => u.id === value);

      if (user) {
        return {
          isValid: true,
          credentials: {
            user: { id: user.id },
            scope: ['internal:session'],
          },
        };
      }

      return { isValid: false };
    },
  },
}).setDescription(
  `Authentication is managed via a <u>secure</u>, HTTP-only session cookie.  
 The cookie is issued after login and must be included in subsequent requests.  
 It is automatically invalidated when the session expires or the user logs out.`
);
Enter fullscreen mode Exit fullscreen mode

At this point, login, authentication, authorization, and logout are all handled in one place.


Plugging everything into the app

The last steps are almost anticlimactic.

We extend the app:

await app.extend([yarPlugin, cookieSessionAuth]);
Enter fullscreen mode Exit fullscreen mode

Set a default auth strategy:

app.base().auth.default({
  strategies: [cookieSessionAuth.getStrategyName()],
  mode: 'try',
});
Enter fullscreen mode Exit fullscreen mode

And finally, protect the API documentation behind authentication:

app.base().ext('onPostAuth', (request, h) => {
  // restrict access to api docs
  if (request.path.startsWith('/docs/')) {
    if (!(request.auth.isAuthenticated && request.auth.credentials.scope?.includes('internal:session'))) {
      return h.redirect('/login').takeover();
    }
  }

  return h.continue;
});
Enter fullscreen mode Exit fullscreen mode

If a user isn’t authenticated, they get redirected to /login. No middleware soup. No scattered guards.


Wrapping up

This is what Auth Designs are really about.

They’re not just validators. They’re ownership boundaries. When done right, authentication stops being something you sprinkle across routes and starts being something you plug in.

In the next part of this series, we’ll push this further: multiple providers, OAuth flows, and tighter integration with external identity systems.

But for now, you’ve got:

  • Cookie-based sessions
  • Login and logout
  • Route-level authorization
  • And a clean mental model

That’s a solid foundation.


Source Code

Curious to see the full app, judge the architecture, or spot a typo?

👉 github.com/shygyver/kaapi-monorepo-playground
The example lives under authorization-app.
See the README for instructions on running it.

More Kaapi articles are coming! It has plenty more tricks up its sleeve.


📦 Get started now

npm install @kaapi/kaapi
Enter fullscreen mode Exit fullscreen mode

🔗 Learn more: https://github.com/demingongo/kaapi/wiki


Top comments (0)