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:
- Issuer exists — the per-tenant OIDC authority your identity model lives under
- Client configured — the application your users will sign in through
- Organization created — your first tenant
- User or credential authenticated — an organization-scoped API key verified against AuthPI
- 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_secretInstall 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-configurationMilestone: 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
- Interactive login — wire the client from step 2 into your app: Next.js, Hono, or a plain TypeScript backend
- Validate user tokens in your API with JWKS: Validate tokens
- Let tenants grow themselves with invitations and organization lifecycle
- Push, don't poll — subscribe to identity events with webhooks
- Give AI agents an identity with short-lived attested tokens: Agents quickstart