Authentication
Say who can call your API. An identity is a credential you accept, a guard turns it into the caller, and the handler receives them typed.
Authentication is still settling. k.identity and an identity's guard may change while v2 is in beta, so pin your version if you depend on them.
An identity is a credential your API accepts. A guard turns it into the caller. What that caller may do is Access Control.
Pick your credential
An identity and its guard go together:
A token in the Authorization header.
export const user = k.identity
.bearer({
context: z.object({
userId: z.string(),
}),
})
.guard(async ({ bearer, deny }) => {
const session = bearer ? await verifySession(bearer.token) : undefined;
if (!session) {
return deny({
status: 401,
body: {
detail: 'Unauthorized',
},
});
}
return {
userId: session.userId,
};
});A value in a header, a query parameter or a cookie. A browser session is in: 'cookie'.
export const member = k.identity
.apiKey({
name: 'x-workspace-token',
in: 'header',
context: z.object({
workspaceUserId: z.string(),
}),
})
.guard(async ({ apiKey, deny }) => {
const membership = apiKey ? await findMembership(apiKey.value) : undefined;
if (!membership) {
return deny({
status: 403,
body: {
detail: 'Forbidden',
},
});
}
return {
workspaceUserId: membership.userId,
};
});A cookie travels on its own
The browser attaches it to any request, including ones another site triggers. Set SameSite on the cookie and check the Origin header
on unsafe methods.
A username and password, decoded from the Authorization header.
export const operator = k.identity
.basic({
context: z.object({
operatorId: z.string(),
}),
})
.guard(async ({ basic, deny }) => {
const operator = basic ? await verifyOperator(basic.username, basic.password) : undefined;
if (!operator) {
return deny({
status: 401,
body: {
detail: 'Unauthorized',
},
});
}
return {
operatorId: operator.id,
};
});A credential no OpenAPI scheme describes, such as a token in a path segment. The guard reads it itself.
export const inviteToken = k.identity
.custom({
context: z.object({
inviteId: z.string(),
}),
params: z.object({
token: z.string(),
}),
})
.guard(async ({ params, deny }) => {
const invite = await findInvite(params.token);
if (!invite) {
return deny({
status: 404,
body: {
detail: 'Not found',
},
});
}
return {
inviteId: invite.id,
};
});oauth2 and openIdConnect are on OAuth.
An identity with no context only proves the caller is known. Its guard returns nothing and handlers get no argument for it.
Name it on your config
The identity carries its own guard, so naming it is all there is:
import { user } from './src/identities';
export default defineConfig({
adapter: expressAdapter(),
routes,
auth: {
identities: {
user,
},
},
});The key is the name everything else uses: a route's auth, and the handler's auth.user. An identity a route names has to carry a guard, or the config refuses to assemble.
Say which routes need it
Every route states its rule, so public is an explicit false and a forgotten route is a type error:
export const users = k.routes('users', {
listUsers: k
.route({
method: 'GET',
path: '/users',
auth: 'user',
responses: {
200: z.array(UserSchema),
},
})
.handler(/* ... */),
});The rest of what an auth can say is on Access Control.
Read the caller
Every guarded route hands its handler what the guard returned, typed, under auth:
listUsers: k
.route({
method: 'GET',
path: '/users',
auth: 'user',
responses: {
200: z.array(UserSchema),
},
})
.handler(async ({ auth }) => ({
status: 200,
body: {
users: await findUsersExcept(auth.user.userId),
},
})),A guard returns the identity's context, field for field. Whether that caller may call the route is decided from it before the handler runs, in Access Control. Which rows are theirs is the handler's question.
Denying
deny({ status, body, headers }) answers with Problem Details, the same shape throwError takes in a handler. A 401 carries the WWW-Authenticate challenge RFC 9110 requires, named after the identity that denied it. A cookie or API key has no challenge to send, so those answer 403.
What a refusal says
A refusal carries detail alone until guardSchema widens it:
export const GuardSchema = Kizuna.model({
title: 'GuardDenial',
schema: ProblemDetailsSchema.extend({
code: z.enum(['unauthenticated', 'expired_token', 'forbidden']).default('forbidden'),
}),
});
export default defineConfig({
adapter: expressAdapter(),
routes,
auth: {
identities: {
user,
member,
},
guardSchema: GuardSchema,
},
});return deny({
status: 401,
body: {
detail: 'Unauthorized',
code: 'expired_token',
},
});Every field you add must be optional or carry a .default(), since Kizuna sends this body itself when Access Control refuses a caller. defineConfig throws when it cannot.
What else a guard gets
The route's path params, the adapter's native request objects, and any request context your config declares. A request-scoped value that gates nothing, such as a logger, is a request context rather than an identity.
What it drives
defineConfig puts 401 on every guarded route, as Problem Details, with WWW-Authenticate required when every identity on the route has a challenge and optional when only some do. Declaring a 401 yourself is an error.
generateOpenApi emits your identities as components.securitySchemes. A custom identity has no scheme, so its routes carry an x-kizuna-guarded extension instead.
The fetch client sends the credential in baseHeaders, and MCP endpoints run the same guards per tool call.