Multi-Tenant IdP
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) andid.acme.at(rp_id =id.acme.at), each usable only on its own origin. - Issued JWTs carry the
issclaim 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:
- Per-request tenant config —
event.context.openapeRpConfig(populated by your middleware). - Static module config —
openapeIdp.rpID,rpOrigin,rpName,issuerfromnuxt.config.ts. - Dev fallback —
rpID = '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
})
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
issvalues, or - Use DDISA discovery to validate
issagainst 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:
- Add DNS record pointing to the deploy host.
- Add hostname to
rpHostAllowList(env ornuxt.config.ts) and redeploy. - Add nginx vhost + expand the LE cert to cover the new SAN.
- 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:
- Export the old DB.
- Import
users,credentials,ssh_keys,registration_urls,signing_keys,grantsinto the multi-tenant DB, stampingrp_id = <old-hostname>on everycredentialsandwebauthn_challengesrow. - On email collisions, keep the canonical row and attach the imported credentials to it (they're keyed by email in the FKs).
- 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.
Related
- nuxt-auth-idp — Module options and store interfaces
- Auth Overview — Authentication modes (WebAuthn, SSH, federated)