Domains and Public Endpoints
A tenant's network identity has two parts. Domains say which hostnames resolve to the tenant. Public endpoints say, per service (OID4VCI issuer, OID4VP verifier, OAuth AS), what host and path layout that tenant's metadata advertises. Both are managed through the Platform Admin API; the services read both at runtime.
Domains: Who Resolves Here
A domain binds a tenant to a fully-qualified host that resolves to it. Two kinds:
- Platform subdomain. A host of the form
<slug>.<platform-base>, owned by the deployment. Auto-created at tenant registration and verified immediately, because the platform controls DNS for<platform-base>. The tenant administrator does nothing for it to work. - Custom domain. A host the tenant brings, such as
wallet.acme.com. Added unverified; the tenant completes a DNS challenge to prove ownership. The resolver skips unverified custom domains, so the record can sit in place through the verification window without routing traffic prematurely.
A tenant can have many domains; a domain belongs to exactly one tenant, and the host is globally unique. The host is stored lowercased, without scheme or port.
Verification Flow
Custom domains follow a DNS challenge:
- The tenant administrator adds the domain with
POST /tenants/{tenantId}/domains(kindCUSTOM_DOMAIN). The EDK mints a verification token and stores the record unverified. - The administrator publishes the token as a DNS TXT record (the exact record format is defined by the deployment).
- Verification (operator-triggered through
POST /tenants/{tenantId}/domains/{domain}/verify, or a deployment's verification worker) checks DNS and, on success, marks the domain verified. From that point the resolver returns the tenant for that host.
The EDK ships the token field and the verify operation so a deployment can plug in its DNS verification worker without re-inventing the model.
Primary Domain
One domain per tenant can be marked primary. This is the canonical host the AS metadata renders when no service-specific public-endpoint binding overrides it. Typical patterns:
- Single platform subdomain:
acme.saas.comis automatically primary. - Migration to a custom domain: the operator marks
wallet.acme.comprimary; the platform subdomain stays as a fallback. - Multiple custom domains: only one is primary; the others are valid resolution targets but do not appear in advertised URLs unless they also carry a public-endpoint binding.
Public Endpoints: What URLs the Tenant Advertises
A public-endpoint binding is the source of truth for what URLs the services put in metadata, in credential_offer_uri, in OID4VP request_uri_base, in the OAuth issuer, in jwks_uri, and so on. The services consult these bindings rather than synthesising URLs from the request host.
Each binding ties a (tenant, serviceType) pair to:
serviceType. One ofOID4VCI_ISSUER,OID4VP_VERIFIER, orOAUTH2_AUTHORIZATION_SERVER. The set is closed because each service type has its own URL grammar.host. The lowercased host without scheme or port. When set, it must be a verified custom domain for the same tenant. When unset, the endpoint uses the runtime host or the default base.pathPrefix. The protocol route prefix, for example/acme/oid4vcito root the tenant's issuer endpoints under a per-tenant slug.wellKnownPath. The well-known route, for example/.well-known/openid-credential-issuer/acme.enabled. Lets an operator disable a binding without deleting it.primaryEndpoint. A primary marker for service types that benefit from one (typically the AS).
The full request and response shapes are in the Platform Admin API reference.
Why a Separate Binding
A binding is separate from the domain because one host can carry several services and one service can be advertised on a non-domain-shaped URL. A tenant acme registered with acme.saas.com might split issuer, verifier, and AS onto distinct ingress hosts (acme.issuer.saas.com, acme.verifier.saas.com, acme.as.saas.com). The three bindings make those choices explicit, per service type, independently of which host a request landed on. For a simpler topology where everything lives at acme.saas.com, all three bindings point at the same host. The model handles both uniformly.
Fail-Closed Default
When no binding exists for the resolved tenant and service type, the runtime refuses to advertise anything rather than falling back to the request host. This is the deliberate production default: the request host is often the cluster's internal host or the CDN's origin host, neither suitable for wallet-facing metadata. A development deployment may flip the runtime to use the request host by setting tenant.public_endpoint.fallback_to_request_host = true; the published Helm chart keeps the fail-closed default.
What the Services Advertise
Each service advertises its URLs from the tenant binding:
- Issuer. The
/.well-known/openid-credential-issuer/{tenant}document advertises the tenant-boundcredential_issuer, credential endpoint, nonce endpoint, deferred endpoint, and notification endpoint. The credential-offer endpoint producescredential_offer_uriandstatus_urirooted at the same binding. - Verifier. The authorization request carries
request_uri_baserooted at the tenant's OID4VP base,direct_postresponses carryresponse_uriat the tenant's response endpoint, andstatus_uriis rooted at the tenant's OID4VP backend base. - AS. The
issuer,jwks_uri,authorization_endpoint,token_endpoint,userinfo_endpoint,end_session_endpoint, and federation callback URLs all come from the tenant binding.
REST Surface
Domains and public endpoints are managed under the Platform Admin API. See the reference for request and response schemas.
# Domains
GET /tenants/{tenantId}/domains
POST /tenants/{tenantId}/domains
DELETE /tenants/{tenantId}/domains/{domain}
POST /tenants/{tenantId}/domains/{domain}/verify
# Public endpoints
GET /tenants/{tenantId}/public-endpoints
PUT /tenants/{tenantId}/public-endpoints/{serviceType}
DELETE /tenants/{tenantId}/public-endpoints/{serviceType}
The {tenantId} in the path is the operation target; the acting tenant comes from the JWT. A JWT bound to tenant A asking to mutate tenant B is rejected by the cross-tenant authorization check.
upsertTenantPublicEndpoint is a PUT against {serviceType} because at most one binding per (tenant, serviceType) is allowed. Asking to PUT against OID4VCI_ISSUER with a body that names OID4VP_VERIFIER is rejected with a 400.
Hardening
The public-endpoint surface is security-relevant: a wrong update could redirect a tenant's wallet flows to an attacker-controlled host. The guarantees:
- Cross-tenant mutation is rejected. A JWT bound to tenant A cannot mutate tenant B's bindings, even with B's tenant id.
- Unverified domains are rejected. A binding's host must reference a verified custom domain for the same tenant.
- Default-host collisions are rejected. A binding cannot point at the deployment's default host in a way that would shadow another tenant's primary.
- Duplicates are rejected. At most one enabled binding per
(tenant, serviceType). - Route/body mismatches are rejected. The service type in the path must match the body.