Skip to content
KortixKortix
Esc
↑↓navigate↵open⌘Jpreview
On this page

Authentication

Pick a credential, wire getToken, sign people in headlessly, add Sign in with Kortix to your own app, and isolate requests on a server.

Every SDK request carries one bearer credential. createKortix reads it from your getToken function and sends it as Authorization: Bearer <token>. This page is for developers who call Kortix from a script, a backend, or an app that people sign in to.

import { createKortix } from '@kortix/sdk';

const kortix = createKortix({
  backendUrl: 'https://api.kortix.com/v1',
  getToken: async () => process.env.KORTIX_API_KEY ?? null,
});

await kortix.projects.list();

On a self-hosted instance, set backendUrl to <your API origin>/v1.

Choose a credential

Pick the row that matches the caller. A credential holds the roles of the principal it acts as, and nothing more.

Caller Credential Acts as Get it
A script, a CI job, your own backend Personal access key, kortix_pat_ You, with your roles. Optionally one project only. Settings → Personal access keys, or kortix tokens new
Unattended automation that must not act as a person Service account key, kortix_sa_ The service account. It has no access until you assign it a role. Account settings → API keys, or kortix tokens service-accounts new
Your product, where people sign in with their Kortix email, password, magic link, social login or SSO The person’s access token from headless sign-in The person who signed in kortix.auth.* + createKortixSession
Your own web app with a “Sign in with Kortix” button OAuth access token, kortix_oat_ (1 hour; refresh token kortix_ort_, 30 days) The person who approved your app createKortixAuth
Code inside a Kortix App App viewer token. The App’s viewer scope must be api. The person viewing the App, capped by their role kortix apps access <app> --viewer api, then kortixAppViewerToken(). See Apps.
Code inside a session sandbox KORTIX_TOKEN The session’s agent (its service account), limited to that project and session. Its permissions are the agent’s kortix_permissions capped by its ceiling role. See What an agent may do. Kortix sets it, with KORTIX_API_URL as the backendUrl. See Sandboxes & previews.

Creation, expiry, project binding and key rules are in API keys and tokens. A web app that already signs people in through the instance’s Supabase passes that JWT: getToken: async () => (await supabase.auth.getSession()).data.session?.access_token ?? null. New code uses headless sign-in instead. A valid credential without the needed permission gets 403. Grant the role in Accounts & access.

getToken and 401 recovery

The SDK calls getToken before every request. It caches nothing, so your code owns token storage and refresh.

  • getToken returns null: the SDK sends no request and throws AuthError with code: 'NO_SESSION'.
  • The API answers 401: the SDK calls getToken.invalidate(rejectedToken) when you attached one, calls getToken again, and replays the request once with the new token. A second 401 goes to the caller.
  • Parallel 401s on the same token share one invalidate call and one getToken call. A host that rotates a refresh token per call is not raced by its own requests.
  • The SDK does not replay when the new token equals the rejected one, when the request body is a ReadableStream, or when the caller set its own Authorization header.

A getToken that caches must attach invalidate. Without it, the second getToken call returns the same dead token and the SDK does not replay. session.getToken from createKortixSession has no invalidate. A 401 on a token that has not expired goes to the caller: handle it with isAuthFailure.

let cached: string | null = null;

const getToken = Object.assign(
  async () => (cached ??= await fetchTokenFromMyServer()),
  { invalidate: (rejected: string) => { if (cached === rejected) cached = null; } },
);

const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken });

To branch on an auth failure, use isAuthFailure(error). It is true for AuthError and for any error with status 401 or 403. It reads the status, never the message text.

import { isAuthFailure } from '@kortix/sdk';

try {
  await kortix.projects.list();
} catch (error) {
  if (isAuthFailure(error)) redirectToSignIn();
  else throw error;
}

Set clientVersion to your app’s name and version, for example clientVersion: 'my-app/1.4.0'. The SDK sends it as X-Kortix-Client-Version. Kortix writes it to request logs only. It grants nothing and never reaches the audit log, which records the credential getToken returned.

Headless sign-in

kortix.auth.* signs people up and in through the Kortix API at /v1/auth/*. It needs no Supabase URL or key and works the same on Kortix Cloud and on a self-hosted instance. Use it for a CLI, a native app, or your own sign-in screen.

Create one session store and one client. The store keeps the tokens, refreshes them 60 s before they expire, and feeds getToken.

import { createKortix, createKortixSession } from '@kortix/sdk';

const session = createKortixSession({
  storage: {                                   // optional: anything with get/set/remove
    get: () => localStorage.getItem('kortix'),
    set: (value) => localStorage.setItem('kortix', value),
    remove: () => localStorage.removeItem('kortix'),
  },
});

const kortix = createKortix({
  backendUrl: 'https://api.kortix.com/v1',
  getToken: session.getToken,
});

const { session: tokens, user } = await kortix.auth.signInWithPassword({ email, password });
await session.set(tokens, user);

await kortix.projects.list();                  // runs as that person

Create the store with createKortixSession directly. Do not build a second createKortix client only to reach kortix.auth.session: every createKortix call shares one process-wide config, and the SDK warns when a second call changes getToken.

Sign-in flows

Flow Calls
Sign up auth.signUp({ email, password, data?, redirect_to? }) returns { user, session, requires_email_confirmation }. When requires_email_confirmation is true, session is null until the person confirms with the emailed link or with verifyOtp({ type: 'signup' }).
Password auth.signInWithPassword({ email, password })
Magic link or code auth.sendMagicLink({ email, create_user?, redirect_to?, data? }), then auth.verifyOtp({ email, token, type: 'magiclink' }) with the code from the email
Social login (PKCE) auth.signInWithProvider({ provider, redirect_to, scopes? }) returns { url, code_verifier }. Keep code_verifier, send the browser to url, then call auth.exchangeCode({ code, code_verifier }) with the code from the redirect. provider is one the instance has configured, for example google or github.
Enterprise SSO auth.signInWithSso({ domain, redirect_to? }) returns { url } to send the browser to. A 404 means the domain has no identity provider. See Single sign-on.
Password reset auth.resetPassword({ email, redirect_to? }), then auth.verifyOtp({ email, token, type: 'recovery' }), then auth.updatePassword({ password }, session.access_token) with the session verifyOtp returned. The new password is 8 to 256 characters.

Every flow that returns a session returns { session, user }. Pass both to session.set(...). redirect_to must be an absolute http or https URL that the instance allows.

The signed-in person

These calls take a token as an argument, so they work before getToken is wired. user, updateUserMetadata, updatePassword and mfa.* need the person’s own access token. They refuse any other credential, such as a personal access key, with 401 invalid_token.

Call Does
auth.user(accessToken) Returns { user }
auth.updateUserMetadata({ data }, accessToken) Writes profile metadata: display name, avatar, language. A password or email change is refused.
auth.updatePassword({ password }, accessToken) Sets a new password
auth.mfa.enroll({ factor_type, friendly_name?, phone?, issuer? }, accessToken) Enrolls a totp or phone factor
auth.mfa.challenge(factorId, { channel? }, accessToken) Starts a challenge
auth.mfa.verify(factorId, { challenge_id, code }, accessToken) Verifies the code. Returns the new token pair at the second assurance level at the top level (access_token, refresh_token, expires_at), not as { session, user }. Store it with session.set(result as AuthSession).
auth.mfa.unenroll(factorId, accessToken) Removes a factor
auth.refresh({ refresh_token }) Rotates the token pair. createKortixSession calls it for you.
auth.signOut(accessToken, { scope? }) Revokes the session at Kortix. scope: global (default, every device), local (this session), others (every other session).

To sign out, revoke the session, then clear the store:

const token = await session.getToken();
if (token) await kortix.auth.signOut(token);
await session.clear();

The session store

kortix.auth.session(options?) is the same function as createKortixSession(options?). Each call creates a new store, so create one and keep it.

Option Default Does
storage none, memory only { get, set, remove }, sync or async: localStorage, a file, a cookie jar, a database row
onChange none Called after every sign-in, refresh and sign-out
refreshSkewSeconds 60 Refresh this many seconds before expiry
request the client’s backendUrl { backendUrl?, fetch?, signal? } for refresh calls
Method Does
getToken() A valid access token, refreshed first when needed. null when signed out.
set(session, user?) Stores a session a kortix.auth.* call returned
current(), user() The stored session and user. No refresh.
refresh() Rotates now. Concurrent callers share one refresh.
subscribe(listener) Calls listener on every change. Returns an unsubscribe function.
load() Reads storage now instead of on first use
clear() Forgets the session locally. Call auth.signOut first to revoke it.

A refresh that Kortix rejects with a 4xx signs the person out: the store clears itself and getToken() returns null. A network failure keeps the stored session for the next call.

Errors and limits

Every kortix.auth.* call throws HeadlessAuthError with code, message and status.

code status Cause
sso_required 403 The email’s domain must sign in with SSO. Sign-up, password and magic link refuse it. Use signInWithSso.
signup_closed 403 The instance does not accept sign-ups from this address
over_request_rate_limit 429 Too many attempts. Wait for the retry-after header.
invalid_token 401 A call that needs the person’s access token got another credential
not_configured 0 No backendUrl: create the client first

All public /v1/auth/* routes share one limit: 30 attempts per minute per client IP, counted on each API replica. Other codes come unchanged from the identity service.

Sign in with Kortix

Sign in with Kortix makes Kortix the identity provider for a web app you run. People sign in with their Kortix account. Your server knows who they are, and your app calls Kortix as that person, with that person’s roles. It is OAuth 2.1 with PKCE, and the browser never holds a Kortix token.

Register your app

Open Account settings → API keys → OAuth apps and select Register app, or register it in code. Registering needs token.create on the account.

const app = await kortix.iam.oauthClients.create(accountId, {
  name: 'My dashboard',
  client_type: 'confidential',   // 'public' for a browser or native app: PKCE, no secret
  redirect_uris: ['https://dashboard.example.com/api/kortix/auth/callback'],
  scopes: ['profile', 'email', 'kortix'],
});
// app.client_id; app.client_secret is shown once

Kortix compares redirect URIs byte for byte. They must use https, except on a loopback host (localhost, *.localhost, 127.0.0.1, [::1]). kortix.iam.oauthClients also has list, get, update, rotateSecret and remove. Field details are in OAuth apps and connected apps.

Create the auth kit on your server

// lib/kortix-auth.ts (server only)
import { createKortixAuth } from '@kortix/sdk/server';

export const auth = createKortixAuth({
  backendUrl: 'https://api.kortix.com/v1',
  clientId: process.env.KORTIX_OAUTH_CLIENT_ID!,
  clientSecret: process.env.KORTIX_OAUTH_CLIENT_SECRET,   // omit for a public client
  redirectUri: 'https://dashboard.example.com/api/kortix/auth/callback',
  cookieSecret: process.env.KORTIX_AUTH_COOKIE_SECRET!,   // at least 32 characters
});

Mount one catch-all route

// app/api/kortix/auth/[...kortix]/route.ts (Next.js App Router)
import { auth } from '@/lib/kortix-auth';

const handle = (request: Request) => auth.handler(request);
export { handle as GET, handle as POST };

Any framework works: pass a Web Request to auth.handler and return its Response.

Gate pages and call Kortix as the person

import { auth } from '@/lib/kortix-auth';

export async function GET(request: Request) {
  const gate = await auth.requireViewer(request);
  if (gate.response) return gate.response;          // 302 to /refresh or /signin

  const kortix = await auth.kortix(request);        // acts as the signed-in person
  return Response.json(await kortix.projects.list());
}

Call Kortix from the browser

import { createKortix } from '@kortix/sdk';
import { SignInWithKortix, useKortixViewer } from '@kortix/sdk/react';

const kortix = createKortix({
  backendUrl: window.location.origin + '/api/kortix/auth/proxy',
  getToken: async () => 'kortix-session',
});

function Header() {
  const { status, viewer } = useKortixViewer();
  if (status === 'signed-in') return <span>{viewer.email}</span>;
  return <SignInWithKortix className="button" />;
}

The browser sends the placeholder bearer kortix-session. The /proxy route replaces it with the person’s real token on your server. Every SDK call works through it, including useSession.

Options

Option Default Does
backendUrl required Kortix API base, including /v1
clientId required The registered client id
clientSecret none The client secret. Omit it for a public client.
redirectUri required Absolute callback URL, identical to a registered one
cookieSecret required At least 32 characters. Encrypts the cookies with AES-256-GCM.
scopes ['profile', 'email', 'kortix'] Scopes to request
basePath the redirectUri path without /callback Where handler is mounted
sessionTtlSeconds 2592000 (30 days) How long a session lives without a new sign-in
cookieSameSite 'lax' 'none' only for an app that signs in while embedded in another origin. It needs https; over http the cookie stays Lax.
fetch global fetch Custom fetch for tests or edge runtimes
Scope Grants your app
profile The person’s id, email and account memberships
email The email address
kortix Acting as the person on the whole Kortix API. Without it the token reaches only the identity routes.

Routes the handler serves

All paths are under basePath, /api/kortix/auth in the example.

Path Does
/signin?return_to=/path Starts sign-in with PKCE and a 10-minute transaction cookie, then redirects to Kortix
/callback Exchanges the code, sets the encrypted HttpOnly session cookie, redirects to return_to
/refresh?return_to=/path Rotates the token pair and redirects. requireViewer sends people here.
/signout?return_to=/path Revokes the refresh token at Kortix and clears the cookie
/me The person as JSON, or 401. Refreshes an expired access token.
/proxy/* Forwards the request to the Kortix API as the person

return_to is always reduced to a same-origin path. When the person declines consent, the callback redirects to return_to with ?kortix_auth_error=<code>. A missing or expired transaction answers 400 with error: 'state_mismatch'. A failed code exchange answers with error: 'token_exchange_failed'.

Server and browser API

From API Returns
@kortix/sdk/server auth.viewer(request) { userId, email, accounts, scopes, token, expiresAt } or null. Read-only: it never rotates the refresh token.
@kortix/sdk/server auth.requireViewer(request) { viewer }, or { response } with a redirect to /refresh or /signin
@kortix/sdk/server auth.kortix(request) A request-scoped client that acts as the person. Throws KortixAuthError unauthenticated when signed out.
@kortix/sdk/server auth.signInUrl(returnTo?), auth.signOutUrl(returnTo?) Link targets
@kortix/sdk/react useKortixViewer({ basePath? }) { status, viewer, reload }. status is loading, signed-in, signed-out or error.
@kortix/sdk/react <SignInWithKortix basePath? returnTo? /> A link that starts sign-in and returns to the current page
@kortix/sdk/react kortixSignOutHref(basePath?, returnTo?) The sign-out link target

Use requireViewer to gate a page. A page then never renders signed out for a person whose refresh token is still valid. createKortixAuth throws KortixAuthError with code invalid_options when backendUrl or clientId is empty, redirectUri is not an absolute http or https URL, or cookieSecret is shorter than 32 characters.

useKortixViewer, <SignInWithKortix> and kortixSignOutHref default basePath to /api/kortix/auth. Pass basePath when you mount handler elsewhere.

What the person sees

The first sign-in shows a Kortix consent screen with your app’s name and scopes. Kortix remembers the approval per person and app, so later sign-ins return straight to your app. People revoke an app in code with kortix.accounts.connectedApps.list() and .revoke(clientId), or on the web and in the CLI as described in Connected apps. After a revoke the app’s tokens stop working and the app must ask again.

Discovery for other OAuth clients

The SDK derives every endpoint from backendUrl. A generic OAuth client reads GET /.well-known/oauth-authorization-server on the API origin, also served at /v1/oauth/.well-known/oauth-authorization-server. Open dynamic client registration is at POST /v1/oauth/register. Kortix deletes a self-registered client after 7 days when it has no approval and no token.

Servers that act for many users

createKortix stores its config in one process-wide slot. That is correct for a browser tab, a CLI or a single-tenant job. On a server that handles requests for different people at the same time, the last createKortix call wins for every request in flight. Use @kortix/sdk/server instead. It isolates each request’s config in Node’s AsyncLocalStorage and runs in Node and Bun.

One client per request. createScopedKortix has the same shape as createKortix and never touches the global config.

import { createScopedKortix } from '@kortix/sdk/server';

export async function GET(request: Request) {
  const kortix = createScopedKortix({
    backendUrl: 'https://api.kortix.com/v1',
    getToken: async () => tokenFor(request),
  });
  return Response.json(await kortix.projects.list());
}

A scoped client’s top-level kortix.runtime() throws, because it would resolve another request’s sandbox. Reach a session’s runtime through its handle: const s = kortix.session(projectId, sessionId); await s.ensureReady(); s.runtime.

One scope per request. runWithKortix(config, fn) sets the config for everything fn awaits, including calls on a client made with createKortix.

import { runWithKortix } from '@kortix/sdk/server';

app.use(async (req, res, next) => {
  await runWithKortix(
    { backendUrl: 'https://api.kortix.com/v1', getToken: async () => tokenFor(req) },
    async () => next(),
  );
});

A proxy for your browser code. forwardKortixRequest forwards one request from your own route to the Kortix API with a token your server holds.

import { forwardKortixRequest } from '@kortix/sdk/server';

export async function handle(request: Request) {
  const url = new URL(request.url);
  const rest = url.pathname.replace(/^\/api\/kortix\//, '');
  return forwardKortixRequest({
    request,
    upstreamUrl: 'https://api.kortix.com/v1/' + rest + url.search,
    token: await tokenFor(request),
  });
}

It removes the browser’s cookie and host headers, sets the bearer, keeps streamed response bodies, and removes set-cookie from the answer. When Kortix is unreachable it answers 502 with { "error": "Upstream request failed" }. Point the browser client at the route with any non-null placeholder token: createKortix({ backendUrl: '/api/kortix', getToken: async () => 'proxy' }).

To start sessions from such a server with session secrets and idempotency, see Start sessions from your server.

Was this page helpful?