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.
The four load-bearing ideas
Section titled “The four load-bearing ideas”- The bearer stays server-side. A client-readable access token would be an
XSS-exfiltration risk, so it is omitted from the client
sessionby design. It is persisted on the encrypted Auth.js JWT cookie and read back only in Node-runtime route handlers. - The gateway is the trust boundary. Every consumer RPC is authenticated by validating the bearer through core-identity — the browser is never trusted.
- 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.
- The
subis the only backend identity. core-identity keys the local user on(provider, subject)where the subject is the access-tokensub. No BFF route trusts a client-supplied user id.
The flow, end to end
Section titled “The flow, end to end”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
Resolving the bearer (BFF)
Section titled “Resolving the bearer (BFF)”The server-side bridge is
lib/grpc/auth-metadata.ts.
authCallOptions(req) calls resolveBearer(req), which has two paths:
- Web (cookie). No
Authorizationheader ⇒ read the token off the encrypted Auth.js session cookie withgetToken(), refreshing on demand if it is near expiry. If nothing usable is found it throwsUnauthenticatedError(carrying a coarse, non-sensitivereason), 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).
Getting a JWT, not an opaque token
Section titled “Getting a JWT, not an opaque token”This is the single most common thing to get wrong on a Logto setup.
Two non-obvious details in
auth.config.ts:
resourcemust ride the token request, not just authorize. Auth.js forwardsauthorization.paramsonly to the authorize step and ignorestoken.params, so with resource on authorize only, Logto’s code exchange still returns its opaque userinfo token. The fix is acustomFetchthat injectsresourceinto the tokenPOSTbody (gated on a form body so only the token endpoint is touched).prompt=consentis required for a refresh token. Logto only returns a refresh token foroffline_accesswhen consent is presented (unless the app’s non-OIDC-compliant “always issue” toggle is on). Without it, sessions goRefreshAccessTokenErroron 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.
The gateway trust boundary
Section titled “The gateway trust boundary”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:
-
Verify the JWT. The
TokenValidator(app/core/auth/token.py) fetches the signing key from Logto’s JWKS viaPyJWKClientand checks signature, issuer, and audience. The allowed algorithms are the asymmetric set only (RS/ES/PS × 256/384/512) — neverHS*/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. -
Resolve the user by
(provider, subject)whereproviderislogtoandsubjectis the tokensub. Thatsubis the only backend identity. -
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 againstGET {endpoint}/api/users/{sub}(management.py). AprimaryEmailon the Logto record is treated as verified. -
Provision and converge.
ingest.upsert_from_externallinks 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”Environment variables (names only)
Section titled “Environment variables (names only)”| 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) |
Related
Section titled “Related”- Configure Logto OIDC (backend + frontend) — the task-first setup recipe
- core-identity — the validate + provision service field guide
- core-gateway-consumer — the trust boundary that calls validate
- Frontend architecture — the SPA + BFF that holds the bearer
- Request lifecycle — where auth sits in the full generation path
- Known limitations — the blank-user bug and other debt