Skip to content

Identity & auth: Logto + JIT provisioning

Login is OIDC against self-hosted Logto via Auth.js (next-auth v5) in the SaaS app, and the whole design turns on one property: the upstream access token never reaches the browser. It lives only on the encrypted server-side Auth.js session cookie. The browser only ever calls /api/* on its own origin; the thin BFF attaches the bearer, and the consumer gateway re-validates it on every single RPC through core-identity, which resolves — or just-in-time provisions — the local user.

  1. The bearer stays server-side. A client-readable access token would be an XSS-exfiltration risk, so it is omitted from the client session by design. It is persisted on the encrypted Auth.js JWT cookie and read back only in Node-runtime route handlers.
  2. The gateway is the trust boundary. Every consumer RPC is authenticated by validating the bearer through core-identity — the browser is never trusted.
  3. The IdP must issue a JWT access token. Logto is opaque by default; the frontend must request an API resource so the token becomes a JWKS-verifiable JWT.
  4. The sub is the only backend identity. core-identity keys the local user on (provider, subject) where the subject is the access-token sub. No BFF route trusts a client-supplied user id.
sequenceDiagram
  autonumber
  actor U as User (browser)
  participant FE as saas-2.0 (Auth.js SPA + BFF)
  participant LG as Logto (OIDC)
  participant GW as core-gateway-consumer
  participant ID as core-identity
  participant MG as Logto Management API
  participant DB as core-database
  U->>FE: Sign in
  FE->>LG: OIDC PKCE (resource=AUTH_LOGTO_RESOURCE, prompt=consent)
  LG-->>FE: JWT access_token + rotating refresh_token
  Note over FE: token material kept on the encrypted server-side<br/>Auth.js JWT — never sent to the browser
  U->>FE: app action (browser hits /api/* on its own origin)
  FE->>GW: gRPC + Authorization: Bearer access_token
  GW->>ID: POST /v1/auth/validate (internal bearer)
  ID->>LG: verify signature vs JWKS + iss + aud
  ID->>DB: resolve user by (provider=logto, sub)
  alt new subject, or existing user still has no email
    ID->>MG: GET /api/users/{sub} (M2M) — email / name / avatar
    ID->>DB: JIT provision + converge by verified email
  end
  ID-->>GW: {valid, user_id, email, subject}
  GW-->>FE: response
  FE-->>U: JSON

The server-side bridge is lib/grpc/auth-metadata.ts. authCallOptions(req) calls resolveBearer(req), which has two paths:

  • Web (cookie). No Authorization header ⇒ read the token off the encrypted Auth.js session cookie with getToken(), refreshing on demand if it is near expiry. If nothing usable is found it throws UnauthenticatedError (carrying a coarse, non-sensitive reason), which JSON routes map to HTTP 401.
  • Native / mobile (header-first). In code An explicit Authorization: Bearer <token> header is forwarded verbatim and the cookie/refresh machinery is skipped — the device owns its own token lifecycle (refresh against Logto, retry on 401). A header that is present but malformed is rejected rather than silently falling back to the cookie (an explicit header signals deliberate header-auth; honouring a cookie could forward a different identity than the caller asserted).

This is the single most common thing to get wrong on a Logto setup.

Two non-obvious details in auth.config.ts:

  • resource must ride the token request, not just authorize. Auth.js forwards authorization.params only to the authorize step and ignores token.params, so with resource on authorize only, Logto’s code exchange still returns its opaque userinfo token. The fix is a customFetch that injects resource into the token POST body (gated on a form body so only the token endpoint is touched).
  • prompt=consent is required for a refresh token. Logto only returns a refresh token for offline_access when consent is presented (unless the app’s non-OIDC-compliant “always issue” toggle is on). Without it, sessions go RefreshAccessTokenError on first expiry and 401 until re-login.

AUTH_LOGTO_RESOURCE (frontend) and IDP_AUDIENCE (backend) are separate env names for the same indicator — easy to set one and forget the other. Social connectors are jumped to directly via direct_sign_in (one Auth.js provider id per IdP); the base logto provider backs phone-OTP.

Every consumer RPC passes through the AuthInterceptor in core-gateway-consumer/app/grpc_server/interceptors/auth.py. It strips an exact Bearer prefix, calls core-identity /v1/auth/validate, and stashes the resolved CallerIdentity on a ContextVar visible to the servicer and every downstream await. Only gRPC health probes are exempt. No route reads a client-supplied session.user.id — identity is resolved gateway-side from the validated bearer sub.

Validation + JIT provisioning (core-identity)

Section titled “Validation + JIT provisioning (core-identity)”

/v1/auth/validate is internal-only (callers present the shared INTERNAL_RPC_SECRET). It does four things:

  1. Verify the JWT. The TokenValidator (app/core/auth/token.py) fetches the signing key from Logto’s JWKS via PyJWKClient and checks signature, issuer, and audience. The allowed algorithms are the asymmetric set only (RS/ES/PS × 256/384/512) — never HS*/none, which would enable an alg-confusion attack. Logto signs with ES384 by default (Zitadel used RS256), so pinning RS256 only would reject every token.

  2. Resolve the user by (provider, subject) where provider is logto and subject is the token sub. That sub is the only backend identity.

  3. Enrich the profile from Logto’s Management API — but only when provisioning a new subject, or healing an existing user that still has no email. The resource-scoped JWT the frontend holds is, by Logto’s design, rejected by /oidc/me, so email / name / avatar / phone are read server-to-server via an M2M client-credentials token against GET {endpoint}/api/users/{sub} (management.py). A primaryEmail on the Logto record is treated as verified.

  4. Provision and converge. ingest.upsert_from_external links the subject to an existing user by verified email before ever forking a new one, handles the concurrent-create race (ALREADY_EXISTS), and re-points a subject that resolved a forked user onto the canonical owner. First provision also runs legacy 1.0 migration and the signup token grant (idempotent) — see the money path and core-migration.

Known bug: blank-email users on stale tokens

Section titled “Known bug: blank-email users on stale tokens”
Name Side Points at
AUTH_SECRET frontend Encrypts the Auth.js session JWT
AUTH_LOGTO_ID / AUTH_LOGTO_SECRET frontend The Logto OIDC application credentials
AUTH_LOGTO_ISSUER frontend Logto issuer URL (ends in /oidc)
AUTH_LOGTO_RESOURCE frontend API-resource indicator → makes the token a JWT
IDP_ISSUER core-identity Logto issuer (alias ZITADEL_OIDC_ISSUER)
IDP_JWKS_URL core-identity JWKS endpoint (alias ZITADEL_JWKS_URL)
IDP_AUDIENCE core-identity Expected aud = the resource indicator (alias ZITADEL_OIDC_AUDIENCE)
LOGTO_ENDPOINT core-identity Logto base URL (host of both /oidc and /api)
LOGTO_M2M_CLIENT_ID / LOGTO_M2M_CLIENT_SECRET core-identity M2M app for Management-API profile fetch
LOGTO_MANAGEMENT_RESOURCE core-identity Management API indicator
INTERNAL_RPC_SECRET fleet Internal bearer for /v1/auth/validate (alias INTERNAL_AUTH_SECRET)