Authentication

Declare who can call your API. Identities describe the credential, guards verify it, and the handler receives the caller typed.

Supports
BearerAPI KeyBasicOAuth 2.0OpenID Connect
Beta

Authentication is still settling. Kizuna.identity and server.guard may change before v2, so pin your version if you depend on them.

An identity describes a credential your API accepts. A guard verifies it and returns who the caller is. The auth map says which routes need which identity.

Declare an identity

Kizuna.identity picks the credential's shape. Its context schema is what a guard returns and handlers receive:

identities.ts
import { z } from 'zod';
import { Kizuna } from '@ts-kizuna/core';

export const user = Kizuna.identity.bearer({
    context: z.object({
        userId: z.string(),
    }),
});

Register it on new Kizuna() under the name the rest of the contract refers to it by:

k.ts
import { Kizuna } from '@ts-kizuna/core';
import { tags } from './tags';
import { user } from './identities';

export const k = new Kizuna({
    identities: {
        user,
    },
    tags,
});

Say which routes need it

k.auth types the map against your routes and identities. Every group must appear, so public is an explicit false:

auth.ts
import { k } from './k';
import { routes } from './routes';

export const auth = k.auth(routes, {
    health: false,
    members: 'user',
});
contract/index.ts
import { k } from './k';
import { routes } from './routes';
import { auth } from './auth';

export const contract = k.contract({
    routes,
    auth,
});

Verify the credential with a guard

One guard per identity, registered on server.api. It returns the identity's context, or denies:

guards.ts
import { server } from './server';

export const requireUser = server.guard('user', async ({ bearer, deny }) => {
    const session = bearer ? await verifySession(bearer.token) : undefined;
    if (!session) {
        return deny(401, 'Unauthorized');
    }
    return {
        userId: session.userId,
    };
});
api.ts
import { server } from './server';
import { router } from './router';
import { requireUser } from './guards';

export const api = server.api({
    router,
    guards: {
        user: requireUser,
    },
});

Read the caller in the handler

Every route under members now runs the guard, and its handler receives the guard's return, typed, under auth, keyed by the identity's name:

members.ts
members: {
    listMembers: ({ auth }) => ({
        status: 200,
        body: {
            members: findMembersExcept(auth.user.userId),
        },
    }),
},

Split into its own file, a handler keeps the typed auth via RouteHandler<typeof contract.routes.members.listMembers>.

Identities

One builder per authentication method: bearer, apiKey, basic, oauth2, openIdConnect, and custom. Each optionally takes context (what a guard returns and handlers receive) and access (fields the auth map may gate per route):

identities.ts
export const member = Kizuna.identity.apiKey({
    name: 'x-workspace-token',
    in: 'header',
    context: z.object({
        workspaceUserId: z.string(),
    }),
    access: z.object({
        role: z.enum(['owner', 'admin']),
    }),
});

Omit context for an authentication-only identity, a pure gate like an API key that only proves the caller is a known client. Its guard returns nothing on success (or deny(...)), and routes secured by it receive no handler arg for it:

identities.ts
export const apiConsumer = Kizuna.identity.apiKey({
    name: 'x-api-key',
    in: 'header',
});

When the credential doesn't fit any OpenAPI scheme, a capability-URL token in a path segment for instance, use custom. Its guard reads the credential itself, and the route stays explicitly guarded:

identities.ts
export const inviteToken = Kizuna.identity.custom({
    context: z.object({
        inviteId: z.string(),
    }),
});

All builders and their fields, including how custom shows up in the spec: Kizuna.identity.

The auth map

Keyed by route group; every group must appear, so public is always an explicit false. Each entry is one of:

ValueMeaning
falsePublic, no auth
'user'Requires the user identity
{ member: { role: 'owner' } }Access gate. Requires member with role limited to a value (or an array)
{ user: ['read:events'] }Requires user with these oauth2 scopes
{ user: true, member: { ... } }Multiple identities, all must pass
{ '*': ..., login: false }Cascade. '*' sets the group default, named keys override its routes and subgroups
auth.ts
export const auth = k.auth(routes, {
    users: false,
    health: false,
    members: 'user',
    workspace: {
        '*': 'member',
        deleteWorkspace: {
            member: {
                role: 'owner',
            },
        },
        transfer: {
            member: {
                role: 'owner',
            },
        },
    },
});

A route entry inherits the '*' default and only states its delta. deleteWorkspace above keeps requiring member, tightened to role: 'owner'. Adding another identity ({ user: true }) requires both; false opts the route out. Identity names are type-checked, and security can't be written inline in k.routes.

Nested groups

A subgroup key covers its whole subtree, or nests its own cascade to opt out deeper. A key that matches nothing in the group is an error:

auth.ts
members: {
    '*': 'user',
    session: {
        '*': 'user',
        login: false,
    },
    invites: false,
},

Access gates

{ member: { role: 'owner' } } rejects other roles with a 403 before the handler runs, and inside the handler auth.member.role narrows to 'owner'. Array fields pass when they contain the value ({ member: { permissions: 'invoicing' } }):

workspace.ts
workspace: {
    deleteWorkspace: ({ auth }) => ({
        status: 200,
        body: {
            ok: auth.member.role === 'owner',
        },
    }),
},

A route requiring several identities receives all of them under auth, as in transfer: ({ body, auth }) => ....

Guards

A guard receives the credential extracted and typed, along with the route's path params. For a request to /workspaces/ws_42/members, params.workspaceId is 'ws_42', so the guard can check that the caller belongs to that workspace, not just that the token is valid:

guards.ts
export const requireMember = server.guard('member', async ({ apiKey, params, deny }) => {
    const membership = apiKey ? await findMembership(apiKey.value, params.workspaceId) : undefined;
    if (!membership) {
        return deny(403, 'Forbidden');
    }
    return membership;
});

The return is checked against the identity's schemas. deny(status, detail, headers?) responds with RFC 9457 Problem Details. A 401 also carries the WWW-Authenticate challenge RFC 9110 requires, named after the identity that denied: Bearer, or Basic for a basic one. An API key is not HTTP authentication and has no challenge to send, so 403 is the honest refusal for one. Guards also get the adapter's native request objects and oauth2 scopes. See server.guard. For request-scoped values that gate nothing (analytics ids, loggers), use request context instead.

What the declaration drives

OpenAPI security

generateOpenApi emits securitySchemes and per-route security from the auth map:

openapi.yaml
# generated
components:
    securitySchemes:
        member:
            type: apiKey
            name: x-workspace-token
            in: header
paths:
    /workspace:
        get:
            security:
                - member: []

A custom identity has no scheme to emit, so its routes carry an x-kizuna-guarded extension instead of security, which keeps them distinguishable from public ones.

Client

The new KizunaClient() client sends the credential in baseHeaders:

api-client.ts
const apiClient = new KizunaClient(contract, {
    baseUrl,
    baseHeaders: {
        Authorization: `Bearer ${token}`,
    },
});

MCP

MCP endpoints run the same guards per tool call.

OAuth

When your tokens come from an authorization server (better-auth, Auth0, Keycloak), your API verifies tokens rather than issuing them. OAuth 2.1 calls that a resource server, and setting one up with kizuna is four steps: declare the identity, secure the routes, verify in the guard, and serve the discovery document.

Declare where tokens come from

The identity carries everything about the authorization server, declared once. issuer is the server's identifier (RFC 8414), flows names its endpoints for the OpenAPI spec, and the flow's scopes are the catalogue your routes may require:

identities.ts
import { z } from 'zod';
import { Kizuna } from '@ts-kizuna/core';

export const user = Kizuna.identity.oauth2({
    issuer: 'https://auth.example.com',
    flows: {
        authorizationCode: {
            authorizationUrl: 'https://auth.example.com/oauth2/authorize',
            tokenUrl: 'https://auth.example.com/oauth2/token',
            scopes: {
                'users:read': 'Read users',
                'users:write': 'Create and update users',
            },
        },
    },
    context: z.object({
        userId: z.string(),
    }),
});

For an OpenID Connect server, Kizuna.identity.openIdConnect({ openIdConnectUrl }) is the whole declaration; the issuer derives from the discovery URL.

Secure the routes

The auth map works as for any identity. Name the identity to require a valid token, or attach scopes from the catalogue:

auth.ts
export const auth = k.auth(routes, {
    users: {
        '*': 'user',
        createUser: {
            user: ['users:write'],
        },
    },
});

Verify in the guard

The guard receives the token as oauth2 and the route's required scopes. Check the signature against the authorization server's JWKS, the issuer, and the audience. Deny a missing scope with the insufficient_scope challenge (RFC 6750):

guards.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { bearerChallenge } from '@ts-kizuna/core/adapter';
import { server } from './server';

const jwks = createRemoteJWKSet(new URL('https://auth.example.com/oauth2/jwks'));

export const requireUser = server.guard('user', async ({ oauth2, scopes, deny }) => {
    if (!oauth2) {
        return deny(401, 'Unauthorized');
    }

    let claims;
    try {
        ({ payload: claims } = await jwtVerify(oauth2.token, jwks, {
            issuer: 'https://auth.example.com',
            audience: 'https://api.example.com',
        }));
    } catch {
        return deny(401, 'Invalid or expired token');
    }

    const granted = typeof claims.scope === 'string' ? claims.scope.split(' ') : [];
    if (!scopes.every((scope) => granted.includes(scope))) {
        return deny(403, 'The token is missing a required scope', {
            'www-authenticate': bearerChallenge({
                error: 'insufficient_scope',
                scope: scopes.join(' '),
                resource_metadata: 'https://api.example.com/.well-known/oauth-protected-resource',
            }),
        });
    }

    return {
        userId: String(claims.sub),
    };
});

A 401 from deny carries the Bearer challenge automatically; the third argument is only needed when the challenge has something to say, as with missing scopes.

Serve the discovery document

Clients that do not know your authorization server find it through RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource. Serve it on the framework, next to api.mount:

src/metadata.ts
import { buildProtectedResourceMetadata } from '@ts-kizuna/core';
import { user } from './identities';

export const metadata = buildProtectedResourceMetadata({
    resource: 'https://api.example.com',
    scheme: user,
});
src/index.ts
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
    res.json(metadata);
});

api.mount(app);
src/index.ts
app.get('/.well-known/oauth-protected-resource', async () => metadata);

await api.mount(app);
src/index.ts
app.get('/.well-known/oauth-protected-resource', (c) => c.json(metadata));

api.mount(app);
app/.well-known/oauth-protected-resource/route.ts
import { metadata } from '@/server/metadata';

export function GET() {
    return Response.json(metadata);
}

The document builds from the identity, so it says what the contract says. resource is the canonical URI of your API, the same value your guard checks as the token audience. First-party apps with a hardcoded authorization server never look at it; serve it when OAuth clients discover your API rather than being configured for it.

See ProtectedResourceMetadataSchema for the document's fields and builders.

Reference

On this page