Ecosystem

Multi-Tenant IdP

Serve multiple OpenApe IdP domains from a single Nuxt process.

Multi-Tenant IdP

@openape/nuxt-auth-idp can host more than one identity origin from a single deployment. A request to id.acme.com and a request to id.acme.at hit the same Nitro process, share the same database, and see the same users — but WebAuthn passkeys, OAuth issuers, and origin checks are scoped per request according to the incoming Host header.

This is the pattern id.openape.ai and id.openape.at use today (one instance, both hostnames). It also lets you bring up a new tenant with an nginx vhost and a DNS record, no redeploy.

Why

WebAuthn passkeys are intrinsically bound to the Relying Party ID (rp.id) they were created against. A passkey registered at https://id.acme.com/ cannot be used at https://id.acme.at/ — browsers enforce that at the credential level. So "multi-tenant" here does not mean one passkey crosses hosts; it means:

  • One deployment, one DB, one user table.
  • The same email can own credentials on both id.acme.com (rp_id = id.acme.com) and id.acme.at (rp_id = id.acme.at), each usable only on its own origin.
  • Issued JWTs carry the iss claim of the hostname the user logged in through.
  • Adding a new hostname is a nginx/DNS task, not a code change.

How it works

The module resolves Relying-Party config through a precedence chain:

  1. Per-request tenant configevent.context.openapeRpConfig (populated by your middleware).
  2. Static module configopenapeIdp.rpID, rpOrigin, rpName, issuer from nuxt.config.ts.
  3. Dev fallbackrpID = 'localhost', origin = http://localhost:3000.

Your middleware inspects each request, matches the Host header against an allow-list, and writes a typed tenant config on event.context. All WebAuthn endpoints and the OAuth iss minter read from that seam first.

Configuration

1. Declare your allow-list

In nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@openape/nuxt-auth-idp'],
  openapeIdp: {
    rpHostAllowList: 'id.acme.com,id.acme.at',
    rpName: 'Acme Identity',
    // rpID/rpOrigin/issuer left empty — resolved per request
  },
})

Or via env:

NUXT_OPENAPE_IDP_RP_HOST_ALLOW_LIST=id.acme.com,id.acme.at

2. Add a tenant middleware

Create server/middleware/00.rp-tenant.ts in your IdP app (the leading 00. ensures it runs before any handler):

import { getRequestHost } from 'h3'
import { useRuntimeConfig } from '#imports'

export default defineEventHandler((event) => {
  const config = useRuntimeConfig()
  const idpCfg = (config.openapeIdp || {}) as Record<string, unknown>
  const raw = (idpCfg.rpHostAllowList as string | undefined) || ''
  const allow = raw.split(',').map(s => s.trim()).filter(Boolean)

  const host = getRequestHost(event, { xForwardedHost: true })?.split(':')[0]
  if (!host || !allow.includes(host)) return

  const origin = `https://${host}`
  event.context.openapeRpConfig = {
    rpName: (idpCfg.rpName as string | undefined) || 'OpenApe Identity Server',
    rpID: host,
    origin,
    requireUserVerification: idpCfg.requireUserVerification ?? false,
    residentKey: idpCfg.residentKey ?? 'preferred',
    attestationType: idpCfg.attestationType ?? 'none',
  }
  event.context.openapeIssuer = origin
})
The allow-list is a security boundary. Without it, a malicious Host: attacker.test header could bind a freshly-created credential to an attacker-chosen RP. Only hostnames present in the allow-list are promoted; everything else falls through to the static module config.

3. Schema: scope credentials by rp_id

The WebAuthn tables gain an rp_id column so the IdP can present only the credentials that match the current request's RP:

ALTER TABLE credentials            ADD COLUMN rp_id TEXT;
ALTER TABLE webauthn_challenges    ADD COLUMN rp_id TEXT;
CREATE INDEX idx_credentials_rp_id ON credentials(rp_id);

In the reference openape-free-idp, this is added idempotently at startup in server/plugins/02.database.ts. Existing rows are backfilled to a default RP (e.g. your canonical hostname) so pre-multi-tenant credentials keep working.

The CredentialStore exposes an optional findByUserAndRp(email, rpId) that every WebAuthn handler calls before registration options, login options, and verification — making cross-RP credential leakage impossible at the data layer.

4. nginx vhost per tenant

Each tenant is a plain vhost that forwards Host to the shared Nitro port:

server {
  listen 443 ssl http2;
  server_name id.acme.at;

  ssl_certificate     /etc/letsencrypt/live/id.acme.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/id.acme.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:3003;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    # …plus standard proxy headers
  }
}

Use a single expanded Let's Encrypt cert for every tenant hostname — certbot --nginx --expand -d id.acme.com -d id.acme.at — or issue one per tenant.

Issuing tokens

When event.context.openapeIssuer is set, the OAuth token minter uses that value as the iss claim instead of the static openapeIdp.issuer. A user who logs in at https://id.acme.at/ therefore receives a JWT with iss = "https://id.acme.at" even though the process itself knows both hostnames.

Consumers (service providers, grant verifiers) should either:

  • Accept a known allow-list of iss values, or
  • Use DDISA discovery to validate iss against the token subject's domain.

jose.jwtVerify() accepts issuer: string | string[], so adding multi-iss acceptance to a service provider is a one-line change.

Adding a new tenant

Once the pattern is in place, onboarding a new hostname is:

  1. Add DNS record pointing to the deploy host.
  2. Add hostname to rpHostAllowList (env or nuxt.config.ts) and redeploy.
  3. Add nginx vhost + expand the LE cert to cover the new SAN.
  4. Done — users can now create passkeys scoped to the new RP.

Migrating data from an existing single-tenant IdP

If you already run a separate IdP for a second hostname and want to collapse it into a multi-tenant deployment:

  1. Export the old DB.
  2. Import users, credentials, ssh_keys, registration_urls, signing_keys, grants into the multi-tenant DB, stamping rp_id = <old-hostname> on every credentials and webauthn_challenges row.
  3. On email collisions, keep the canonical row and attach the imported credentials to it (they're keyed by email in the FKs).
  4. Flip DNS for the old hostname at the new deploy host and expand the TLS cert.

Users whose passkeys were registered against the old hostname keep using them — the rp_id scoping presents only the credentials valid for the request's RP. SSH keys work against both hostnames because they're email-keyed and have no RP binding.