Sign users in to your app
Members of your organisation sign in to your app with their Kapable account. You write no callback and store no secret. Turn sign-in on, then one call reads the user and one call checks a permission.
import { currentUser } from "@kapable/sdk/app-auth";
const user = currentUser(request); // { memberId, orgId, email, role }With sign-in on, you should see the member's id, org id, email and role.
What you get
- Anyone in your organisation can sign in with the Kapable account they already have.
- The Kapable router runs the sign-in. Your app gets no callback route and holds no secret.
- Your code reads who is signed in from the request. The SDK makes no network calls.
- You decide what each role may do in your app, in one small map.
Turn on Kapable sign-in
Do this once per app, in the console. Any member of your organisation can. Your code does not change.
- Open the app, then Settings, then the Authentication section.
- Tick Require Kapable sign-in at the edge.
- Press Save authentication.
On a new app, Sign in with Kapable is already ticked and Kapable audience is already Org members only. On an older app, check both before you save.
From then on, every visitor signs in before a request reaches your app. Someone outside your organisation cannot open it. After sign-in, the visitor lands on the page they asked for.
The same settings are at GET and PUT
/v1/apps/{app_id}/auth-config, for a member session of the
app's organisation (see Authentication).
Each console control maps to one field:
| Console control | API field and value |
|---|---|
| Require Kapable sign-in at the edge | "kapable_auto_adopt": true |
| Sign in with Kapable | "kapable" in "providers" |
| Kapable audience: Org members only | "kapable_audience": "org_members" |
The PUT replaces all the app's sign-in settings, and any
field you leave out goes back to its default. Read the settings with
GET first and send them all back with your change.
Set up your app
The calls work in any Bun or Node app. The examples on this page use Hono. To start fresh, create an app from the Elysia (bun) template, then add Hono and the SDK:
bun add hono @kapable/sdk@^0.23.0
Apps created by Kapable already carry an .npmrc that points
the @kapable scope at the Kapable registry.
Split the app into three files. Your routes go in
src/app.ts, which exports app. Two small entry
files serve it: one for production and one for your laptop.
src/app.ts: your routes
import { Hono } from "hono";
import { html } from "hono/html";
import {
currentUser,
requireUser,
defineAccess,
signOutUrl,
KapableAuthRequired,
KapableForbidden,
} from "@kapable/sdk/app-auth";
export const access = defineAccess({
roles: {
owner: ["*"],
admin: ["notes.read", "notes.write"],
member: ["notes.read"],
"Key keeper": ["notes.read"],
},
});
export const app = new Hono();
app.onError((err, c) => {
if (err instanceof KapableAuthRequired || err instanceof KapableForbidden) {
return err.response;
}
console.error(err);
return c.json({ error: { code: "internal", message: "Something went wrong." } }, 500);
});
app.get("/health", (c) => c.json({ status: "ok" }));
app.get("/", (c) => {
const user = currentUser(c.req.raw);
if (user === null) return c.text("Hello. Sign-in is off for this app.");
return c.html(html`<p>Hello, ${user.email}. <a href="${signOutUrl()}">Sign out</a></p>`);
});
app.get("/me", (c) => {
const user = requireUser(c.req.raw);
return c.json({ email: user.email, role: user.role });
});
app.post("/notes", async (c) => {
const user = access.require(c.req.raw, "notes.write");
// ... save the note for user.memberId
return c.json({ saved: true, by: user.memberId }, 201);
});src/index.ts: production
import { app } from "./app";
export default {
port: Number(process.env.PORT ?? 3000),
fetch: app.fetch,
};src/dev.ts: your laptop only
On your laptop there is no Kapable router, so nobody is signed in. The SDK never invents a user. This file sets the four headers the router would set. Production code never imports it.
import { app } from "./app";
const devUser: Record<string, string> = {
"x-kapable-member-id": "dev-member",
"x-kapable-org-id": "dev-org",
"x-kapable-email": "dev@example.com",
"x-kapable-role": process.env.DEV_ROLE ?? "owner",
};
export default {
port: Number(process.env.PORT ?? 3000),
fetch(request: Request) {
const headers = new Headers(request.headers);
for (const [name, value] of Object.entries(devUser)) headers.set(name, value);
return app.fetch(new Request(request, { headers }));
},
};package.json scripts
Point start at the production file and dev at the laptop file:
{
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --watch src/dev.ts"
}
}bun run dev # signed in as an owner
DEV_ROLE=member bun run dev # signed in as a member
bun run start # nobody signed inRead the user
currentUser(request) tells you who is signed in. With
sign-in on, every request carries a user. It returns null
only when sign-in is off, or on your laptop without the dev headers.
The user has four fields:
| Field | What it is |
|---|---|
memberId | The member's id in your organisation. Use it as the user's key. |
orgId | Your organisation's id. |
email | The member's email, or null. |
role | The member's role name, exactly as the console shows it, or null. |
currentUser accepts a Fetch Request, a Node
IncomingMessage, or a Headers object.
Protect a route
requireUser(request) returns the signed-in user, never
null, so your code needs no null check. If there is no
user, it throws KapableAuthRequired, a clear
401.
With sign-in on, this never throws: the router signs every visitor in
first. So a 401 means sign-in is off for the app, or the
request reached the app without passing the router. The error says so:
{
"error": {
"code": "sign_in_required",
"message": "Sign-in isn't switched on for this app. Any member of your organisation can turn it on in the app's Authentication settings.",
"details": {
"how_to_fix": "Open {org}.kapable.ai/apps/{app_id}/settings → Authentication, tick \"Require Kapable sign-in at the edge\", then Save authentication. Or PUT /v1/apps/{app_id}/auth-config with {\"providers\":[\"kapable\"],\"kapable_audience\":\"org_members\",\"kapable_auto_adopt\":true}."
}
}
}
{org} and {app_id} are placeholders. The SDK
cannot know them. Put in your organisation's slug and your app's id.
The app's settings address shows both.
Permissions
Your app decides what each role may do. Write the map once with
defineAccess, as in src/app.ts above. Check it
with access.require(request, "notes.write").
- No user: the same
401asrequireUser. - The role grants the permission: returns the user.
- The role does not: throws
KapableForbidden, a403.
Use access.can(user, "notes.write") when you need a yes or
no, for example to hide a button. A null user gets
false.
A role you create in the console grants nothing in your app until you
list it in defineAccess. Names match exactly, including
capitals and spaces. A role the map does not list gets no permissions.
"*" grants every permission, and only to the roles that
list it. Built-in roles arrive as owner,
admin, member and viewer.
Changing a role's platform permissions in the console does not change your app's map. Moving a member to another role does.
The 403 body names the permission and the member's role in
details. So when a custom role is missing from your map,
the response tells you which role to add.
{
"error": {
"code": "permission_denied",
"message": "You don't have permission to notes.write. Ask an owner of your organisation.",
"details": { "permission": "notes.write", "role": "Key keeper" }
}
}Sign out
signOutUrl() returns /__kapable/auth/logout.
Link to it, as the home page in src/app.ts does. Opening it
ends the visitor's session in your app and sends them to /.
It does not sign them out of Kapable. If they are still signed in there, opening your app again signs them straight back in.
Send the error response
KapableAuthRequired and KapableForbidden each
carry .response, a ready Fetch Response, plus
.status and .message. Sending it is one line,
as in the onError in src/app.ts.
Show a friendly page in the browser
.response is JSON, which is right for API calls. A person
in a browser sees raw JSON. To show them a page instead, answer
browsers with HTML:
app.onError((err, c) => {
if (err instanceof KapableAuthRequired || err instanceof KapableForbidden) {
const wantsHtml = (c.req.header("accept") ?? "").includes("text/html");
if (!wantsHtml) return err.response;
const title = err instanceof KapableForbidden ? "You can't do that here" : "Sign-in needed";
return c.html(
html`<h1>${title}</h1><p>${err.message}</p><p><a href="/">Back to the start</a></p>`,
err.status as 401 | 403,
);
}
console.error(err);
return c.json({ error: { code: "internal", message: "Something went wrong." } }, 500);
});
API calls still get the JSON body with its code and
details.
Elysia
Pass request from the handler context:
import { Elysia } from "elysia";
import { requireUser, KapableAuthRequired, KapableForbidden } from "@kapable/sdk/app-auth";
const app = new Elysia()
.onError(({ error }) => {
if (error instanceof KapableAuthRequired || error instanceof KapableForbidden) return error.response;
})
.get("/me", ({ request }) => requireUser(request));React Router loaders and actions
import { requireUser, KapableAuthRequired, KapableForbidden } from "@kapable/sdk/app-auth";
export async function loader({ request }: Route.LoaderArgs) {
try {
const user = requireUser(request);
return { email: user.email };
} catch (err) {
if (err instanceof KapableAuthRequired || err instanceof KapableForbidden) {
throw err.response;
}
throw err;
}
}A thrown 401 or 403 reaches your error boundary.
How it works
- A visitor opens your app. The Kapable router sends them to your organisation's sign-in page.
- After sign-in, the router keeps the session in its own cookie on your app's host and sends the visitor back.
- On every request, the router checks that session. If it is valid, the router adds four headers:
x-kapable-member-id,x-kapable-org-id,x-kapable-emailandx-kapable-role. - The SDK reads those headers. That is all it does.
The router removes any x-kapable-* headers a visitor sends
before it adds its own. A visitor cannot pretend to be someone else.
These headers only mean something behind the Kapable router. Never expose your app's port directly. If a request can reach your app without passing the router, anyone can set these headers.
Limits
- This version signs in members of your organisation only.
- This version protects the whole app. Every page needs sign-in. Public pages mixed with protected ones are coming.
- App-only accounts, for customers outside your organisation, are coming. They will use the same calls.
- Sign-out ends the session in your app only, not in Kapable.
- Permissions live in your code. Changing the map means a new deploy.