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:
- Register its own Hapi auth scheme and strategy
- Plug seamlessly into Kaapi routes
- Automatically describe itself in OpenAPI documentation
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();
Once this is running, you can hit /profile with:
Authorization: Bearer secret
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();
Send a standard HTTP Basic header at /profile:
Authorization: Basic YWRtaW46cGFzc3dvcmQ=
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();
By default, it reads from headers, but you can just as easily switch to:
apiKeyAuth.inQuery();
apiKeyAuth.inCookie();
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/yarfor 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' },
];
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,
});
},
};
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>
`;
}
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
/loginand/logoutroutes 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);
},
});
}
}
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.`
);
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]);
Set a default auth strategy:
app.base().auth.default({
strategies: [cookieSessionAuth.getStrategyName()],
mode: 'try',
});
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;
});
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
🔗 Learn more: https://github.com/demingongo/kaapi/wiki




Top comments (0)