Configure Logto OIDC (backend + frontend)
Auth is self-hosted Logto OIDC (Zitadel was migrated off). Login runs
in the frontend via Auth.js (next-auth v5); the upstream access token never reaches the
browser — it lives only on the encrypted server-side Auth.js JWT cookie, and
core-gateway-consumer re-validates it on
every single call by forwarding it as the gRPC bearer to
core-identity /v1/auth/validate.
This page is the wiring recipe: set Logto up so the access token is a JWT (not opaque), point the frontend and core-identity at the same audience, and give core-identity a Management-API client so it can enrich profiles. For the why and the full flow diagram, see Identity & auth.
What you’ll set up
Section titled “What you’ll set up”-
Stand up Logto (infra — out of scope here): the self-hosted deployment plus its Postgres DB. The end-to-end console spec lives in
soundverse-saas-2.0/docs/auth-logto-setup.md. Note the four OIDC endpoints Logto exposes off its base (call it<endpoint>):Purpose URL Issuer <endpoint>/oidcJWKS <endpoint>/oidc/jwksUserinfo <endpoint>/oidc/meEnd-session <endpoint>/oidc/session/end -
Register an API Resource in Logto. Its indicator is the value both
AUTH_LOGTO_RESOURCE(frontend) andIDP_AUDIENCE(backend) will hold. When the frontend requests this resource, the issued access token becomes a JWT whoseaudequals the indicator. -
Create the Traditional Web application the frontend uses. Register a redirect URI per Auth.js provider id —
<origin>/api/auth/callback/logto, plus one for each social id (logto-google,logto-microsoft,logto-discord) — and a post-logout redirect URI for RP-initiated federated logout. Enable “Always issue refresh token” OFF; the frontend gets a refresh token viaprompt=consentinstead (see the trap below). -
Create a Machine-to-Machine (M2M) application for profile enrichment and grant it the Logto Management API access role. core-identity uses its client-credentials to read
GET {endpoint}/api/users/{sub}— this is how email/name/avatar/phone are resolved (the userinfo endpoint can’t be used; see the trap below). -
Enable connectors and account linking. Turn on the Google / Microsoft / Discord social connectors and (for phone) the SMS connector, then enable each in the Sign-in Experience —
direct_sign_insilently falls back to Logto’s own login page if the connector isn’t enabled there. Turn on automatic account linking so one human with a verified email/phone is one Logto user across many identities. -
Wire the frontend env (
soundverse-saas-2.0) — see the table below. -
Wire the core-identity env — see the table below.
-
Verify. Sign in, then confirm the gateway accepts the bearer (a call to any
/api/*returns data, notunauthenticated) and that a freshidentity.usersrow has a non-blank email. If the token is opaque you’ll seeNot enough segmentsin core-identity logs — go back to steps 2 and 6.
Frontend config (soundverse-saas-2.0)
Section titled “Frontend config (soundverse-saas-2.0)”The Auth.js config is split so the gate can run at the edge:
auth.config.ts
is edge-safe (declares the Logto provider(s), scopes, and the authorized gate);
auth.ts is the
full Node instance with the jwt/session callbacks. It registers a base logto
provider (for the phone-OTP button) plus one provider per social connector using
direct_sign_in, all sharing the scope openid profile email phone offline_access.
| Env var (name only) | Points at |
|---|---|
AUTH_SECRET |
Key that encrypts the server-side session JWT |
AUTH_LOGTO_ID |
The Traditional Web app’s client id |
AUTH_LOGTO_SECRET |
The Traditional Web app’s client secret |
AUTH_LOGTO_ISSUER |
<endpoint>/oidc (Auth.js discovers the rest) |
AUTH_LOGTO_RESOURCE |
The API-resource indicator — must equal backend IDP_AUDIENCE |
REDIS_ADDR / REDIS_PASSWORD |
Durable rotating-refresh-token store (see the double-spend trap) |
Backend config (core-identity)
Section titled “Backend config (core-identity)”core-identity validates the JWT locally against the JWKS
(app/core/auth/token.py)
and provisions/enriches the user at /v1/auth/validate
(app/routes/auth.py).
Config lives in
app/core/config.py.
| Env var (name only) | Purpose | Legacy alias |
|---|---|---|
IDP_ISSUER |
OIDC issuer = <endpoint>/oidc |
ZITADEL_OIDC_ISSUER |
IDP_JWKS_URL |
<endpoint>/oidc/jwks |
ZITADEL_JWKS_URL |
IDP_AUDIENCE |
The API-resource indicator (the token’s aud) — must equal AUTH_LOGTO_RESOURCE |
ZITADEL_OIDC_AUDIENCE |
LOGTO_ENDPOINT |
Logto base URL (host of both /oidc and /api) |
— |
LOGTO_M2M_CLIENT_ID |
M2M app client id (Management API) | — |
LOGTO_M2M_CLIENT_SECRET |
M2M app client secret | — |
LOGTO_MANAGEMENT_RESOURCE |
Management-API indicator; defaults to https://default.logto.app/api for single-tenant self-host (a fixed indicator string, not a reachable URL) |
— |
LOGTO_TOKEN_URL |
Token endpoint; defaults to {endpoint}/oidc/token |
— |
IDP_USERINFO_URL |
Legacy OIDC userinfo enrichment (Zitadel rollback only; empty ⇒ skipped) | — |
Each IDP_* setting still accepts its legacy ZITADEL_* name via AliasChoices, so an
un-migrated docker-compose / ACA injection keeps working during a rollback. In staging/prod
these are injected with the STAGING_/PROD_ prefix stripped — see
Environment & service discovery.
Set the IDP_* and LOGTO_* names above in each service’s .env. Keep names only — never
commit real client secrets. For the shared-secret and prefix-injection pattern, see
Configuring .env.
Set the org-level STAGING_/PROD_-prefixed variants (e.g. STAGING_IDP_AUDIENCE,
STAGING_LOGTO_M2M_CLIENT_SECRET); the deploy template strips the prefix and injects them.
The traps that will actually bite you
Section titled “The traps that will actually bite you”JIT provisioning at validate time
Section titled “JIT provisioning at validate time”core-identity provisions just-in-time: any token that passes JWKS validation resolves to an
identity.users row keyed on the token sub, and the first provision of a subject grants the
signup token bundle via ingest.ensure_billing (SIGNUP_GRANT_TOKENS, default 1000). The
gateway is provider-agnostic — it just forwards the bearer.
Related
Section titled “Related”- Identity & auth — the full OIDC sequence and why the bearer stays server-side
- core-identity — the validate + JIT-provisioning service
- core-gateway-consumer — the trust boundary that re-validates every call
- Configuring .env — the shared-secret + prefix-injection pattern
- Environment & service discovery —
STAGING_/PROD_env injection - Environment variable catalog — every fleet env-var name
- Troubleshooting —
Not enough segments, sticky 401, and friends