Discovery — Identifying the user
Using the Discovery service to resolve a user's organization before authentication.
Discovery — Identifying the user
The Discovery service is the gatekeeper of pre-authentication. It resolves a user's (or a hostname's) organization without your code ever needing to know Keycloak's internal identifiers — note however that
network_realm_id(deprecated) is still exposed in some responses for backward compatibility, see below.
Why this step
Before any OAuth2 authentication, your Wakapp needs to know which Keycloak realm to redirect the user to. In a multi-tenant architecture, each Network has its own realm. Discovery resolves this mapping securely, server-side, without your code ever needing to know Keycloak's internal identifiers.
It is also the only public service reachable without a token — by definition, since the user is not yet authenticated.
Key concepts
- Subdomain: short string identifying a Network or Customer (
app.test,acme-prod). May contain dots. - WID: short identifier (6 alphanumeric chars) used for entities (
NET001,ACM001). - Anti-enumeration: latency is normalized (~80ms) on
POST /discover/start-loginand on the/internal/*routes (~50ms). The direct lookupsGET /discover/emailandGET /discover/subdomainhave no latency padding — see the dedicated section below. - Realm: a Keycloak concept — 1 Network = 1 realm. The
network_realm_idfield has been reintroduced in the responses of/discover/subdomainand/discover/email(accepted product decision for backward compatibility, VULN-002); it is marked@deprecatedbut still present — it is not a hidden field.
The 3 available patterns
Pattern 1 — start-login by hostname (recommended)
The recommended flow for single-tenant Wakapps. Discovery automatically resolves the Keycloak client_id from the hostname.
httpPOST {DISCOVERY_API_URL}/discover/start-login Content-Type: application/json { "host": "app.test", "redirectUri": "https://app.test.wakastart.app/api/auth/callback", "state": "550e8400-e29b-41d4-a716-446655440000", "codeChallenge": "<base64url(SHA256(code_verifier))>", "codeChallengeMethod": "S256" }
json{ "keycloakUrl": "https://auth.example.com/realms/<realm>/protocol/openid-connect/auth?client_id=<resolved>&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256" }
Advantages:
- Zero knowledge of the realm or
client_idon the Wakapp side. - Discovery resolves host → Network → App → client_id automatically.
- Removes coupling with the Keycloak naming convention (auto-generated from the WID, e.g.
7OWWAA).
Constraints: the hostname must match the subdomain registered in the database for the target Network.
Pattern 2 — start-login by email (Microsoft-style)
The user enters their email. Discovery identifies their organization and returns the same keycloakUrl.
httpPOST {DISCOVERY_API_URL}/discover/start-login Content-Type: application/json { "email": "john@acme.com", "redirectUri": "https://app.test.wakastart.app/api/auth/callback", "state": "550e8400-e29b-41d4-a716-446655440000", "codeChallenge": "<base64url(SHA256(code_verifier))>", "codeChallengeMethod": "S256" }
Security filtering: Discovery only returns accounts if the email is associated with the App matching the request's Origin (app.url_front). A user with the same email on another app will not show up.
When to use it: if your Wakapp hosts multiple tenants on the same hostname, or if you are implementing a portal-style common login page.
Never supply
hostat the same time — the response will be400 invalid_request.
Pattern 3 — Direct lookup (informational)
To display information before login (logo, organization name, IDP type) or after login for the app catalog.
httpGET /discover/email?email=john@acme.com
json{ "users": [{ "user_email": "john@acme.com", "customer_wid": "ACM001", "customer_name": "Acme Corp", "customer_subdomain": "acme", "network_wid": "NET001", "network_subdomain": "acme-prod", "partner_wid": "PTR001", "idp_id": "uuid-idp", "idp_type": "OIDC", "keycloak_org_alias": "ACM001", "app_wid": "APP001", "app_name": "Acme Dashboard", "app_client_id": "acme-app", "app_url_front": "https://acme.example.com" }] }
Essential fields:
| Field | Usage |
|---|---|
network_subdomain / customer_subdomain | Identifies the logical tenant |
app_client_id | Keycloak clientId (useful if you want to force an appId in start-login) |
keycloak_org_alias | Keycloak organization alias (customer.wid), usable as kc_idp_hint |
idp_type | OIDC or SAML (if a federated IDP is configured) |
network_realm_idis reintroduced and exposed by/discover/subdomainand/discover/email(VULN-002: accepted product decision for backward compatibility). The field is marked@deprecatedbut is still present in the response — it is not a hidden field.
Lookup by subdomain
httpGET /discover/subdomain?subdomain=acme-prod
json{ "subdomain": "acme-prod", "result": { "type": "network", "network_wid": "NET001", "network_subdomain": "acme-prod", "partner_wid": "PTR001", "app_wid": "APP001", "app_client_id": "acme-app", "app_url_front": "https://acme.example.com" } }
Useful for pre-filling the context (logo, name) when the login page loads. The response is { result: null } if the subdomain is unknown — same format, normalized latency.
Application lookup
httpGET /discover/apps/customer?customerId={uuid_or_wid} GET /discover/apps/user?userId={uuid} GET /discover/apps/network?networkId={uuid}
Returns the applications accessible for a given entity. To be used after login to build an app catalog or a cross-app navigation menu.
Extracting the subdomain
The subdomain can be multi-label (contains dots). Naming convention:
text{subdomain}.{platform-domain} app.test.wakastart.app └──┬───┘ └──────┬─────┘ subdomain platform domain
Recommended extraction function (TypeScript):
typescriptfunction extractSubdomain(hostname: string): string { if (!hostname) return ""; // Méthode 1 : variable d'env (recommandée) const suffix = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN_SUFFIX; // ex: "wakastart.app" if (suffix && hostname.endsWith(`.${suffix}`)) { return hostname.slice(0, -(suffix.length + 1)); } // Méthode 2 : fallback — strip les 2 derniers labels DNS const labels = hostname.split("."); if (labels.length <= 2) return ""; // localhost ou domaine simple return labels.slice(0, -2).join("."); } // Exemples (suffix = "wakastart.app") : // "app.test.wakastart.app" → "app.test" // "acme.wakastart.app" → "acme" // "localhost" → "" (mode dev local)
Database validation rule:
network.subdomainaccepts^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$— alphanumeric + hyphens + dots, must start and end with an alphanumeric character.
Subdomain vs. email — when to use which
| Criterion | host (hostname) | email |
|---|---|---|
| Single-tenant Wakapp | Recommended | Not necessary |
| Multi-tenant portal | Not suitable | Recommended |
| The user knows their org | Recommended | Optional |
| The user only knows their email | Not applicable | Recommended |
| Security (resistance to enumeration) | Better | Good (Origin filtering) |
Anti-enumeration and throttling
[!WARNING] Anti-enumeration is not uniform across the entire service.
POST /discover/start-loginand the/internal/*routes normalize the latency of negative responses.GET /discover/emailandGET /discover/subdomain, on the other hand, return the full tenant payload as soon as there is a match, with no latency padding whatsoever — the existence of an account or tenant remains observable through the response content on these two routes. This is a transitional state accepted during the wakapp auth migration; do not rely on these two endpoints for a flow that requires strong resistance to enumeration.
| Measure | Detail |
|---|---|
| Uniform response (JSON shape) | { users: [] } or { result: null } — the key is always present, whether the entity exists or not |
| Normalized latency | ~80ms on /discover/start-login, ~50ms on /internal/*. Absent on GET /discover/email and GET /discover/subdomain |
| Short rate limit | 5 requests / 60s per IP |
| Medium rate limit | 20 requests / 600s (10 min) per IP |
| Long rate limit | 100 requests / 3600s (1 h) per IP |
Mandatory UX rule: always display a generic message such as "Check your email and try again", never "This email does not exist".
Error handling
| Status | Body | Cause | Action |
|---|---|---|---|
400 | invalid_request | email + host provided simultaneously, or neither | Check the body |
400 | invalid_request | Missing or malformed body | Check Content-Type and payload |
404 | not_found | No matching Network | Display a generic message |
429 | Too Many Requests | Rate limit exceeded | Exponential backoff, Retry-After header |
Best practices
- Call
start-loginonly once per login attempt — do not automatically retry on404. - Extract the subdomain from
window.location.hostnameusing theextractSubdomainfunction, never hardcode it. - Store
client_idandrealm(extracted from the returnedkeycloakUrl) in sessionStorage before the redirect to Keycloak. - Never log the email addresses passed to Discovery — they are personal data.
- Test locally with
subdomain=""and implement a manual entry field for dev mode.
Common pitfalls
- Incorrect subdomain extraction: sending
host="app"instead ofhost="app.test"→404 not_found. See theextractSubdomainfunction above. - Hardcoded
appId: do not sendappIdunless you are managing several apps on the same hostname. Discovery resolves it from the host automatically. email+hosttogether:400 invalid_request. Choose one or the other.- Empty response treated as an error:
{ users: [] }is a valid response meaning "not found". Do not trigger an application crash.
Learn more
- Authentication — OAuth2 PKCE: using the
keycloakUrlreturned bystart-login - Common pitfalls: diagnosing frequent Discovery errors