Quickstarts

Multi-tenant identity in 15 minutes

The golden path — create an issuer, configure a client, create an organization, authenticate an org-scoped credential, and read the identity event trail.

Last updated 2026-07-01

Overview

This is the canonical AuthPI setup: the shortest end-to-end path from an empty project to a working multi-tenant identity model. You will hit five milestones, in order:

  1. Issuer exists — the per-tenant OIDC authority your identity model lives under
  2. Client configured — the application your users will sign in through
  3. Organization created — your first tenant
  4. User or credential authenticated — an organization-scoped API key verified against AuthPI
  5. Identity event received — the event trail your systems can react to

Everything runs from a terminal — no browser required. At the end, your first tenant holds a user and a scoped credential, and every step you took is visible as an event. Interactive user login builds on top of this and is covered by the framework quickstarts (Next.js, Hono).

Prerequisites

  • An AuthPI account and an account API key (created during console onboarding — the secret is shown once, at creation)
  • Node.js 20+ or Python 3.11+

Set your credentials as environment variables:

export AUTHPI_KEY_ID=key_your_key_id
export AUTHPI_KEY_SECRET=your_key_secret

Install the Admin SDK

npm install @authpi/admin
pip install authpi-admin

Construct the client once; every step below reuses it. The SDK authenticates with your API key over HTTP Basic and resolves your account id automatically.

import { AuthPIAdmin } from "@authpi/admin";

const admin = new AuthPIAdmin({
  apiKey: { id: process.env.AUTHPI_KEY_ID!, secret: process.env.AUTHPI_KEY_SECRET! },
});
import os
from authpi_admin import AuthPIAdmin

admin = AuthPIAdmin(
    api_key=(os.environ["AUTHPI_KEY_ID"], os.environ["AUTHPI_KEY_SECRET"]),
)

1. Create your issuer

The issuer is the root of your identity model: users, organizations, clients, and credentials all live under it, and it runs a standards-compliant OIDC endpoint at https://idp.authpi.com/{issuer_id}.

const issuer = await admin.issuers.create({ name: "acme-prod" });

console.log(issuer.id); // i_...
issuer = await admin.issuers.create({"name": "acme-prod"})

print(issuer.id)  # i_...

Verify the OIDC runtime is live — this endpoint is what standard OIDC libraries will discover:

curl https://idp.authpi.com/{issuer_id}/.well-known/openid-configuration

Milestone: issuer exists.

2. Configure a client

A client is an application allowed to authenticate against your issuer — your web app, your CLI, your mobile app. Register one with the authorization-code flow and a local redirect URI; when you wire up interactive login later, this is the client you'll use.

const client = await admin.issuer(issuer.id).clients.create({
  name: "acme-web",
  type: "external",
  confidential: true,
  settings: {
    protocol: "oidc",
    scopes: ["openid", "profile"],
    openid: {
      application_type: "web",
      grant_types: ["authorization_code", "refresh_token"],
      redirect_uris: ["http://localhost:3000/api/auth/callback"],
    },
  },
});

console.log(client.id); // c_...
// client.secret is returned only on this create response.
client = await admin.issuer(issuer.id).clients.create({
    "name": "acme-web",
    "type": "external",
    "confidential": True,
    "settings": {
        "protocol": "oidc",
        "scopes": ["openid", "profile"],
        "openid": {
            "application_type": "web",
            "grant_types": ["authorization_code", "refresh_token"],
            "redirect_uris": ["http://localhost:3000/api/auth/callback"],
        },
    },
})

print(client.id)  # c_...
# client.secret is returned only on this create response.

Milestone: client configured.

3. Create an organization

Organizations are your tenants. Each one is a boundary that holds members, groups, invitations, SSO configuration — and its own credentials. This is the part of the model that makes an API multi-tenant rather than multi-user.

const org = await admin.issuer(issuer.id).organizations.create({
  name: "Globex Corp",
});

console.log(org.id); // org_...
org = await admin.issuer(issuer.id).organizations.create({
    "name": "Globex Corp",
})

print(org.id)  # org_...

Milestone: organization created.

4. Add a user with scoped membership

Create a user in the issuer, then make them a member of the organization. Membership carries scopes, not roles: resource:action grants that your API evaluates directly. Authorization is deny-by-default, so a membership must hold at least one scope (or group).

const user = await admin.issuer(issuer.id).users.create({
  username_type: "email",
  username: "ada@globex.example",
  profile: { first_name: "Ada", last_name: "Lovelace" },
});

await admin.issuer(issuer.id).organization(org.id).members.create({
  member_id: user.id,
  scopes: ["invoices:read", "invoices:write"],
});
user = await admin.issuer(issuer.id).users.create({
    "username_type": "email",
    "username": "ada@globex.example",
    "profile": {"first_name": "Ada", "last_name": "Lovelace"},
})

await admin.issuer(issuer.id).organization(org.id).members.create({
    "member_id": user.id,
    "scopes": ["invoices:read", "invoices:write"],
})

When Ada signs in through the client from step 2, her tokens carry these organization scopes. In production you'd let organizations grow themselves with invitations instead of creating members by hand.

5. Mint and verify an organization API key

Organizations hold credentials of their own. An organization API key lets Globex's backend call your API without a user session — this is how tenants integrate machine-to-machine. Mint one, scoped to exactly what it needs:

const key = await admin.issuer(issuer.id).organization(org.id).apiKeys.create({
  type: "api_key",
  name: "globex-backend",
  restrictions: { scopes: ["invoices:read"] },
});

// secret_plain is present only on this create response — store it now.
console.log(key.id, key.secret_plain);
key = await admin.issuer(issuer.id).organization(org.id).api_keys.create({
    "type": "api_key",
    "name": "globex-backend",
    "restrictions": {"scopes": ["invoices:read"]},
})

# secret_plain is present only on this create response — store it now.
print(key.id, key.secret_plain)

Now verify it the way your API will in production: present the key to GET /v1/me and read back who it is and what it may do — the equivalent of AWS STS GetCallerIdentity. Your API's job reduces to one call: forward the credential, then enforce the returned scopes.

import { AuthPIAdmin } from "@authpi/admin";

// In your API: construct a client from the credential the caller presented.
const who = await new AuthPIAdmin({
  apiKey: { id: key.id, secret: key.secret_plain },
}).whoami();

// who.type === "api_key" → the credential is valid
// who.organizations[0].org_id → which tenant is calling (org_...)
// who.organizations[0].scopes → what it may do (["invoices:read"])
from authpi_admin import AuthPIAdmin

# In your API: construct a client from the credential the caller presented.
async with AuthPIAdmin(api_key=(key.id, key.secret_plain)) as caller:
    who = await caller.whoami()

# who["type"] == "api_key" → the credential is valid
# who["organizations"][0]["org_id"] → which tenant is calling (org_...)
# who["organizations"][0]["scopes"] → what it may do (["invoices:read"])
curl https://api.authpi.com/v1/me \
  -u "key_the_org_key_id:the_org_key_secret"

# → { "data": { "type": "api_key", "organizations": [ { "org_id": "org_...", "scopes": ["invoices:read"] } ] } }

A 401 means the credential is invalid, expired, blocked, or revoked; a valid response tells you the organization and its scopes. Deny-by-default applies: a key with no matching scope should get a 403 from your API.

Milestone: user or credential authenticated.

6. Read the identity event trail

Every mutation you just made — and the verification you just performed — emitted an event. List them:

const events = await admin.events.list({ limit: 20 });

for (const event of events.data) {
  console.log(event.type);
}
// api-key.verified
// api-key.created
// organization.membership.created
// user.created
// organization.created
// client.created
// issuer.created
page = await admin.events.list(limit=20)

for event in page.data:
    print(event.type)
# api-key.verified
# api-key.created
# organization.membership.created
# user.created
# organization.created
# client.created
# issuer.created

The trail is the point: identity changes in AuthPI produce events your systems can react to, not state you have to poll for. In production, subscribe to lifecycle events with webhooks and AuthPI pushes signed payloads to your endpoint as they happen. (Per-request events like api-key.verified are metered and intentionally never fan out to webhooks — query those here.)

Milestone: identity event received.

What you built

One issuer running a live OIDC endpoint. One client ready for interactive login. One tenant organization holding a member with invoices:* scopes and a machine credential scoped to invoices:read — verified with a single API call. And an event trail recording all of it.

Next steps

© 2026 AuthPI. All rights reserved.