The problem — and why it matters
Every app you ship on DODIL serves two kinds of caller, and they are nothing alike.
The first is a business user: the sales rep in a CRM, the buyer in a storefront, the store manager opening a dashboard. They arrive in a browser, they type an email and a password, and they are emphatically not your DODIL org users — they hold no platform grant, own no service account, and must never be principals on the DataK3 bucket where the app's data lives.
The second is a platform principal: a service account, a CI job, another service on the
org, your own dodil ignite invoke. It arrives with a platform token, has no browser and
no session, and is exactly the thing platform identity (dodil auth, the dodil Keycloak
realm) was built for.
The usual outcome is that you build auth twice — a bolt-on identity vendor (Auth0, Cognito, Clerk) as a fifth bill for the humans, plus a hand-rolled bearer-token check for the machines — and then smear both through every handler. Session cookies, PKCE, JWKS caching, refresh rotation: hundreds of lines of security-critical code in the one place you least want to be original.
On DODIL, which callers your app accepts is a line of configuration. A pool (dodil-appid) owns your business users, their credentials and an Ed25519 signing keypair, so it has its own issuer and JWKS. Attach that pool to an Ignite app and the per-cluster gateway does the entire login — PKCE, cookie, refresh, JWT verification at the edge — then hands your handler an already-verified identity in a request header. Ignite's own release notes put it plainly: the app ships no auth code. Platform principals keep working alongside, because the model is additive, not either/or.
NOTE
The two-plane rule — state it once, hold it forever. appid establishes who the user
is; it does not grant that user any data-plane access. The app still reaches DataK3
exclusively through its own service account (a k3.editor client-credentials token). A
business user is never a DataK3/bucket principal — the app reads the user's
sub/email from the injected identity and does its own row and action authorization.
Pool issuers are unknown to every data plane's trust config, by design.
What you'll prove: create a pool, attach it to a live Ignite app, watch the gateway 401 an
anonymous browser with a login_url and 303 it into a PKCE authorization request, watch a
platform principal sail through the same app unchallenged, and read the injected identity
in a handler that contains no cryptography at all.
Two principal kinds, one config matrix
This is the spine of the post. An Ignite app has two auth-relevant settings —
user_pool (which appid pool's end users may sign in) and public_invoke (the
--allow-unauthenticated flag; anonymous calls allowed or not). Together they decide who gets
in:
user_pool | public_invoke | Who can call the app |
|---|---|---|
| set | false (default) | End-user login required. Anonymous browsers get 401 + login_url. Platform principals still work. |
| set | true | Optional login. Anonymous passes; identity is injected when a pool session or token is present. Platform principals still work. |
| unset | false (default) | Platform principals only — the SA-invoked service. No end-user login exists. |
| unset | true | Fully public. No identity of any kind. |
Read the table twice. user_pool adds end-user login; it never removes platform access.
That is what the CLI means by "ADDITIVE to platform auth — platform callers keep working",
and it is why a private, login-required app is still deployable, invocable and debuggable by
your service accounts.
Inside the app, the two kinds are told apart by one injected header:
X-Dodil-Auth-Source: pool— an app end user from the attached pool.X-Dodil-Auth-Source: platform— a service account or other platform principal.
Branch on that header and nothing else. It is set by the gateway, and any inbound copy a client tries to send is stripped before your app sees the request.
Prerequisites
- The
dodilCLI, logged in (dodil auth login). The org is resolved from your login token — appid commands never take an--org. - An Ignite app you can redeploy (see Ship a DODIL App).
- For the off-gateway path only: any HTTP client (
curl) and, on the API side, Python withpyjwt[crypto]+httpx.
Everything below was run live on 2026-09-05 against prod (org ihdiash, pool
retail-vision, app retail-hq), except where a line is explicitly dated 2026-09-04. The
outputs are the real ones.
Step 1 — Create the pool (its own issuer + JWKS)
A pool is the unit of app identity. It owns its users and an Ed25519 keypair, which is
why it gets its own issuer https://appid.dodil.io/YOUR_ORG/YOUR_POOL and a JWKS at
ISSUER/.well-known/jwks.json. Creating one costs a keypair and a schema — there is no realm
and no per-pool infrastructure. --with-local turns on the email+password connection in the
same step.
Create an app-user pool called retail-vision for my org, with email and password sign-in turned on. Give me its issuer and JWKS URL.
Pool retail-vision created (org ihdiash resolved from your login token). Issuer https://appid.dodil.io/ihdiash/retail-vision; JWKS at that issuer plus /.well-known/jwks.json; audience pool:retail-vision. Local email+password connection enabled — signup and the password grant are live.
# org is resolved from your login token — no --org
dodil appid pool create retail-vision --display-name "Retail Vision" --with-local
# → issuer: https://appid.dodil.io/ihdiash/retail-vision
# jwks_uri: https://appid.dodil.io/ihdiash/retail-vision/.well-known/jwks.json
# audience: pool:retail-vision
# if you didn't pass --with-local, add the connection after the fact:
dodil appid connection add retail-vision local
# optional: require verified email before first login
# dodil appid connection add retail-vision local --config '{"require_email_verification":true}'The pool is now a live OIDC-shaped issuer. Its discovery document and keys are public HTTP — the two endpoints the gateway (and, off-gateway, your own API) read:
export ISSUER="https://appid.dodil.io/ihdiash/retail-vision"
curl -s "$ISSUER/.well-known/openid-configuration"{
"authorization_endpoint": "https://appid.dodil.io/ihdiash/retail-vision/authorize",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["password", "refresh_token", "authorization_code"],
"id_token_signing_alg_values_supported": ["EdDSA"],
"issuer": "https://appid.dodil.io/ihdiash/retail-vision",
"jwks_uri": "https://appid.dodil.io/ihdiash/retail-vision/.well-known/jwks.json",
"response_types_supported": ["code"],
"token_endpoint": "https://appid.dodil.io/ihdiash/retail-vision/token",
"token_endpoint_auth_methods_supported": ["none"],
"userinfo_endpoint": "https://appid.dodil.io/ihdiash/retail-vision/user"
}curl -s "$ISSUER/.well-known/jwks.json"
# → {"keys":[{"alg":"EdDSA","crv":"Ed25519","kid":"21053f8e…","kty":"OKP","use":"sig","x":"E1oZAzx…"}]}The keys are kty:OKP, crv:Ed25519, alg:EdDSA — the pool signs with a modern elliptic
curve, not RSA, and pool rotate-keys keeps the previous key in JWKS until every token it
signed has expired. Only email+password (local) is live today; oauth/oidc connections are
V3 and saml is phase 2. Note code_challenge_methods_supported: ["S256"] and
token_endpoint_auth_methods_supported: ["none"] — that is the public-client PKCE flow the
gateway is about to drive for you.
Where the users physically live. By default a pool's users sit in appid's own control-plane Postgres. If you want them as rows next to the app's data — the Supabase model, so "this rep's deals" is a SQL join — bind the pool's user store to the app's DataK3 bucket explicitly:
# appid stores NO credential: the config carries a service-account client id and
# short-lived tokens are minted at connect time. The SA needs k3.editor on the bucket.
dodil appid user-store use-bucket retail-vision retailvis-core01 --sa cli-retail-vision-app
# identity tables (_appid_*) are created in the bucket BEFORE the config persists⚠️ Repointing does not migrate existing users — bind early, on a fresh pool, or you strand the accounts you already have.
Step 2 — Attach the pool to the app (this is the whole integration)
One field. In .dodil/deploy.yaml, which the CD runner applies after your checks pass:
version: 1
create_if_missing: true
apps:
- name: retail-hq
image: "{registry}/ihdiash/retail-vision:{sha}"
port: 8080
user_pool: retail-vision # "pool" (own org) or "org/pool"; "" detaches
public_invoke: false # false => end-user login REQUIRED (platform still works)
health:
path: /healthz
when:
branches: [main]
require_checks: allOr imperatively, on an app that already exists:
Attach the retail-vision user pool to the retail-hq Ignite app so its end users must log in at the ingress, then show me the app record.
ignite_app_update→ignite_app_getretail-hq updated: user_pool is now ihdiash/retail-vision with public_invoke false, so anonymous browser requests to the public FQDN get a 401 with a login_url and platform principals keep calling it as before. The app record shows user_pool ihdiash/retail-vision, public_invoke false, and public_urls retail-hq-ihdiash-8080.ignite.dodil.cloud.
# attach (additive: platform callers keep working)
dodil ignite app update ihdiash:retail-hq --user-pool ihdiash/retail-vision
# optional login instead of required — anonymous passes, identity injected when present:
# dodil ignite app update ihdiash:retail-hq --allow-unauthenticated=true
# detach, back to platform-principals-only:
# dodil ignite app update ihdiash:retail-hq --clear-user-pool
dodil ignite app get retail-hq -o json{
"id": "ihdiash/retail-hq",
"user_pool": "ihdiash/retail-vision",
"public_invoke": false,
"public_urls": ["retail-hq-ihdiash-8080.ignite.dodil.cloud"],
"deployment_state": "deployed"
}--user-pool is also available on ignite app create and ignite app deploy. The pool must
belong to the app's own org. That is the entire code change: there isn't one.
Step 3 — Allowlist the callback, or you ship a healthy app with a broken login
The gateway drives the pool's authorization-code flow with a redirect URI derived from the
app's own FQDN: https://APP-ORG-PORT.ignite.dodil.cloud/.dodil/auth/callback. The pool
refuses any redirect URI that is not on its allowlist — as it should — so if you skip this the
app is green, the health probe passes, and every login dies at the authorize step.
# the app's FQDN comes from public_urls, and it is <app>-<org>-<port>.ignite.dodil.cloud
dodil appid settings set retail-vision \
--redirect-uris https://retail-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/callback,https://retail-vision-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/callback
dodil appid settings get retail-vision -o json{
"redirect_uris": [
"https://retail-vision-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/callback",
"https://retail-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/callback"
]
}WARNING
appid settings set REPLACES the whole settings object. Flags you pass are stored;
everything else — the other redirect URIs, --allow-signup, --password-min-length, your TTL
overrides — returns to its default. We broke our own login exactly this way: a second
settings set for a second app dropped the first app's callback. Always pass every URI
and re-pass every other setting on each call, or keep the whole settings object in a
script.
Miss it and the failure is unambiguous — this is the live response from the pool's authorize endpoint for an unlisted URI:
curl -s "$ISSUER/authorize?response_type=code&client_id=ingress&redirect_uri=https%3A%2F%2Fnot-allowlisted.example.com%2F.dodil%2Fauth%2Fcallback&state=abc&code_challenge=uzf-nKe…&code_challenge_method=S256"
# HTTP/2 400
# {"error":"redirect_uri is not in the pool's allowlist (settings.redirect_uris)"}Step 4 — What the gateway does for you
With the pool attached, the per-cluster gateway owns three reserved paths on your app's origin
— /.dodil/auth/login, /.dodil/auth/callback, /.dodil/auth/logout — and every
other request passes through its auth check first.
An anonymous request is refused with a pointer, not a redirect. That matters: an API client gets JSON it can act on, and a SPA can decide whether to bounce the user or render a sign-in button.
curl -i https://retail-hq-ihdiash-8080.ignite.dodil.cloud/
# HTTP/2 401
# www-authenticate: Bearer
# {"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}return_to is filled from the path you asked for, so a deep link survives the round trip
(GET /healthz yields login_url: "/.dodil/auth/login?return_to=%2Fhealthz").
Following login_url starts a PKCE S256 authorization request — no client secret anywhere,
because there is nowhere safe to keep one:
curl -i "https://retail-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/login?return_to=%2F"
# HTTP/2 303
# location: https://appid.dodil.io/ihdiash/retail-vision/authorize
# ?response_type=code&client_id=ingress
# &redirect_uri=https%3A%2F%2Fretail-hq-ihdiash-8080.ignite.dodil.cloud%2F.dodil%2Fauth%2Fcallback
# &state=1m_vRk41L3egyUKvpOH_UNkWbvyDY01j
# &code_challenge=uzf-nKeSZNzruirdTFJYNyWQ31gmGwsvrPIn3psOv1Y
# &code_challenge_method=S256
# set-cookie: __dodil_txn=v1.poolc1.…; Path=/; Max-Age=600; HttpOnly; Secure; SameSite=LaxThe __dodil_txn cookie is the short-lived (600s) transaction state that binds the state and
the PKCE verifier to this browser. The user lands on the pool's hosted sign-in page, branded
with the pool's branding settings; on success the gateway exchanges the code at the callback
and seals the result into a host-only session cookie, __dodil_session (chunked as
__dodil_session.0…N when the token set is large). Logout clears the family:
curl -i https://retail-hq-ihdiash-8080.ignite.dodil.cloud/.dodil/auth/logout
# HTTP/2 303 location: /
# set-cookie: __dodil_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax
# set-cookie: __dodil_session.0=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=LaxWhat the gateway is doing, itemised — none of which is your code:
- PKCE S256 against the pool issuer, with
client_id=ingressand no secret at the edge. - The session cookie is AEAD-sealed,
HttpOnly,Secure,SameSite=Lax, and host-only (noDomainattribute). JavaScript in the page can never read it. - Single-flight refresh: when the access token ages out, exactly one in-flight refresh runs and the rest of the concurrent requests wait on it, rather than N parallel refreshes racing the pool's rotation + reuse detection.
- JWT verification at the edge — EdDSA, with the trust anchors selected by
issfrom the control plane. There is no appid client registration and no secret to distribute.
Step 5 — Read the identity in your app (no cryptography)
On every request that clears the check, the gateway injects three headers. Any inbound copy of these headers is stripped first, so a client cannot forge them — which is precisely what lets your handler trust them without verifying a signature.
| Header | Contents |
|---|---|
X-Dodil-User | JSON convenience object: sub, email, connection, app_roles |
X-Dodil-User-Jwt | The full verified token — this is where the catalog-expanded permissions claim lives |
X-Dodil-Auth-Source | pool (an app end user) or platform (a service account / platform principal) |
The whole app-side integration:
# app/auth.py — behind the Ignite gateway.
# NO signature verification here: the gateway already verified the token, and it stripped
# any inbound copy of these headers before the request reached us.
from fastapi import Request, HTTPException
def auth_source(request: Request) -> str:
return request.headers.get("X-Dodil-Auth-Source", "") # "pool" | "platform" | ""
def current_user(request: Request) -> dict:
"""The signed-in business user. Only meaningful when auth_source == 'pool'."""
raw = request.headers.get("X-Dodil-User")
if not raw:
raise HTTPException(401, "end-user login required")
u = json.loads(raw) # sub, email, connection, app_roles
return {
"sub": u["sub"], # stable user id — your filter value
"email": u.get("email"),
"connection": u.get("connection"), # "local"
"roles": u.get("app_roles", []),
}
def permissions(request: Request) -> list[str]:
"""Permissions are NOT in X-Dodil-User — they ride the verified JWT."""
tok = request.headers.get("X-Dodil-User-Jwt")
if not tok:
return []
body = tok.split(".")[1]
body += "=" * (-len(body) % 4) # base64url padding
return json.loads(base64.urlsafe_b64decode(body)).get("permissions", [])Two details worth pinning down, because they are the ones people get wrong:
- Do not verify the signature again. It is not "defence in depth" here, it is a second
JWKS fetch on your hot path and a second place to get
audhandling wrong. The header cannot reach you from outside; if it is present, the gateway put it there. X-Dodil-Userhas nopermissions. It carriessub,email,connection,app_rolesand nothing more. Role→permission expansion lives in the token, so authorization readsX-Dodil-User-Jwt. That split is the subject of the companion post, App Authorization on DODIL.
And then the two-plane rule, in code — the verified sub is a filter value, never a
credential:
@app.get("/deals")
def my_deals(request: Request):
user = current_user(request)
if "deals:read" not in permissions(request):
raise HTTPException(403, "missing deals:read")
# the app talks to DataK3 as ITSELF (its k3.editor service account), filtering by sub.
# the app-user is never a bucket principal:
return db.execute(text("SELECT * FROM deals WHERE owner_id = :uid"), {"uid": user["sub"]}).all()The browser holds only the gateway's session cookie. No token in JavaScript, no password ever seen by your code, and the app's service account remains the only thing on the box with a data-plane credential.
Platform principals: the other half of the matrix
retail-hq above is public_invoke: false with a pool attached — an anonymous browser gets a
401. So does a platform caller, right? No. Same app, same moment, a platform principal:
dodil ignite invoke retail-hq --timeout 45s
# OK Invocation complete
# HTTP Status: 405
# Result: {"detail": "Method Not Allowed"}That 405 is FastAPI's — POST / isn't a route on this app. Which is the point: the request
reached the application. The gateway admitted a platform principal to a login-required app
without a session cookie and without a pool token, exactly as the additive model says it
should. Your CI, your service accounts and your own CLI keep working on an app that humans must
log in to.
Design your handlers around that:
src = auth_source(request)
if src == "platform":
... # machine caller: no X-Dodil-User; authorize by the platform grant
elif src == "pool":
user = current_user(request) # a signed-in business user
else:
... # only reachable on a public_invoke app with no session — treat as anonymousIf your app should be machines-only, don't attach a pool at all: no user_pool +
public_invoke: false is the SA-invoked service, and nothing about it changes.
Path B — off the gateway: verify the pool JWT yourself
Everything above applies to an app hosted on Ignite with a pool attached. Two real cases fall outside that, and for them the classic JWKS verification is still exactly right:
- Your app runs somewhere else — on-prem, another cloud, a laptop — but its users live in a DODIL pool.
- A machine-to-machine caller holds a pool token (obtained with the password or refresh grant) and hits an API directly, with no browser and no gateway session in front of it.
In those cases the app is the relying party, and it must do the verification the edge would have done.
Get a token: signup, password grant, refresh
local is Supabase-shaped: the client calls the pool's REST endpoints directly. POST /signup
creates the user and returns a token pair in one shot; POST /token with the password grant
is the login. Passwords are hashed with argon2id.
Sign up a test business user on the prod-smoke pool with a password, then log that user in and show me the token pair.
Signup created the user (email_verified false, app_roles empty) and returned an access_token with expires_in 900 plus a refresh_token. The password grant on the token endpoint returns the same shape — a 15-minute EdDSA access token plus a rotating refresh token.
export ISSUER="https://appid.dodil.io/ihdiash/prod-smoke"
# create the user (returns a token pair immediately)
curl -s "$ISSUER/signup" \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"S3cur3-passphrase!"}'
# subsequent logins: the password grant on /token
curl -s "$ISSUER/token" \
-H 'content-type: application/json' \
-d '{"grant_type":"password","email":"[email protected]","password":"S3cur3-passphrase!"}'
# when the 15-minute access token expires, rotate — do NOT re-prompt for the password
curl -s "$ISSUER/token" \
-H 'content-type: application/json' \
-d '{"grant_type":"refresh_token","refresh_token":"REFRESH_TOKEN_HERE"}'{
"access_token": "eyJhbGciOiJFZERTQS…",
"expires_in": 900,
"refresh_token": "…",
"token_type": "bearer",
"user": {
"app_roles": [],
"email": "[email protected]",
"email_verified": false,
"id": "57e11c12-…"
}
}The access token lives 15 minutes (expires_in: 900, tunable with
settings set --access-ttl-secs). Refresh tokens rotate on every use, with reuse
detection: present an old refresh token — a sign it leaked — and the whole token family is
revoked. Admins can provision accounts without the public signup path at all
(dodil appid user create POOL EMAIL --roles rep, or omit --password to send them through the
invite/reset flow) — pair that with settings set --allow-signup=false for the ERP mode.
The claims
{
"iss": "https://appid.dodil.io/ihdiash/prod-smoke",
"aud": "pool:prod-smoke",
"sub": "57e11c12-…",
"email": "[email protected]",
"connection": "local",
"app_roles": [],
"amr": ["pwd"],
"iat": 1757000000,
"exp": 1757000900
}iss is the pool's issuer and aud is pool:POOLNAME — the two you must pin. sub is the
stable user id; connection and amr record how they signed in.
Verify it — iss AND aud mandatory
Verification is stateless — no call back to appid per request. Fetch the pool's JWKS (cache
it ~5 min), pick the key by kid, decode with EdDSA, and pin both the issuer and the
audience. This exact code ran live:
def verify(token, issuer, audience): # e.g. audience="pool:prod-smoke"
jwks = httpx.get(f"{issuer}/.well-known/jwks.json",
headers={"User-Agent": "myapp/1.0"}, timeout=10).json()
kid = jwt.get_unverified_header(token)["kid"]
key = next(k for k in jwt.PyJWKSet.from_dict(jwks).keys if k.key_id == kid)
return jwt.decode(token, key.key, algorithms=["EdDSA"], issuer=issuer, audience=audience)
# NEVER options={"verify_aud": False} — verifying only the signature is the anti-pattern.- The explicit
User-Agentis required, not cosmetic.appid.dodil.io's edge returns 403 to the stdlib default agent; set any real product string and the JWKS fetch succeeds. issuerandaudienceare both mandatory. Verified live: a token with a valid signature but the wrongaudis rejected —jwt.decoderaisesInvalidAudienceError. Optional-aud (options={"verify_aud": False}) is the anti-pattern the platform forbids: it would let a token minted for another pool through your front door.
If you find yourself writing this inside an app you are about to deploy to Ignite, stop and
attach a user_pool instead. This code is correct for Path B and redundant for Path A.
Same origin, one image
The gateway's session cookie is host-only — no Domain attribute, so the browser sends it
to retail-hq-ihdiash-8080.ignite.dodil.cloud and nowhere else. That single fact decides your
topology:
- One image serving the SPA and the API on one port is what makes the cookie flow work.
Static assets and
/api/*behind the same FQDN; the browser holds a cookie and nothing else. - Split the UI and API across two FQDNs and the cookie stops being sent to the API. You are then forced to put a token into the browser — and the only credentials lying around are the app's machine credentials. Never ship a service-account secret to a client.
- If you must split, make the UI app a BFF: it holds the session and proxies to the API server-side, so no credential ever crosses into JavaScript.
This is also why the split-app rule in the Ignite playbook stays pragmatic: split for a real reason (a public surface vs. a private engine, independent scaling, a distinct trust boundary), not by reflex — and when you do, keep the browser talking to exactly one origin.
One-shot
Authentication for a DODIL app = configuration, two principal kinds (org from your login token):
0. MATRIX: user_pool set + public_invoke false → end-user login REQUIRED, platform principals still work.
user_pool set + public_invoke true → optional login (anonymous passes, identity injected if present).
no user_pool → platform principals only (or fully public with public_invoke true).
X-Dodil-Auth-Source: pool | platform ← how the app tells them apart.
1. Pool: dodil appid pool create retail-vision --with-local
→ issuer https://appid.dodil.io/ORG/retail-vision ; JWKS ISSUER/.well-known/jwks.json ; aud pool:retail-vision
(users default to appid's control-plane store; to co-locate them with app data:
dodil appid user-store use-bucket retail-vision BUCKET --sa SA_ID — does NOT migrate, bind early)
2. Attach: .dodil/deploy.yaml → user_pool: retail-vision + public_invoke: false
or dodil ignite app update ORG:retail-hq --user-pool ORG/retail-vision
(--allow-unauthenticated=true for optional login; --clear-user-pool to detach)
3. Allowlist the callback (or login 400s with "redirect_uri is not in the pool's allowlist"):
dodil appid settings set retail-vision --redirect-uris https://APP-ORG-PORT.ignite.dodil.cloud/.dodil/auth/callback
⚠ settings set REPLACES ALL settings — re-pass every URI and every other flag each time.
4. PATH A (on Ignite, the default): the gateway does PKCE S256 + AEAD-sealed host-only __dodil_session
cookie + single-flight refresh on /.dodil/auth/login|callback|logout, verifies the JWT at the edge,
STRIPS inbound copies and injects:
X-Dodil-User {sub,email,connection,app_roles}
X-Dodil-User-Jwt the verified token — the ONLY place the expanded "permissions" claim lives
X-Dodil-Auth-Source pool | platform
The app TRUSTS these headers and verifies NO signature. Anonymous → 401
{"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}.
5. PATH B (NOT behind the gateway — app hosted elsewhere, or an M2M caller with a pool token):
POST ISSUER/signup | ISSUER/token (password | refresh_token; access TTL 900s, refresh rotates + reuse detection).
Verify yourself: JWKS with an explicit User-Agent (edge 403s the stdlib default), key by kid,
jwt.decode(algorithms=["EdDSA"], issuer=ISSUER, audience="pool:NAME"). BOTH iss and aud mandatory.
6. Same origin: the session cookie is host-only → serve UI + API from ONE FQDN, or use a BFF.
Never ship a machine credential to the browser.
7. Two-plane rule: an app-user is NEVER a bucket principal. The app reaches DataK3 via its OWN
k3.editor service account and uses sub as a FILTER value.Connect your tools
- Behind the gateway there is nothing to connect — that is the feature. Any framework that
can read a request header can read
X-Dodil-User; there is no SDK, no middleware package, and no JWKS client to configure. A Go, .NET, Rust or TypeScript app is the same three headers. - Off the gateway, any JWKS-aware JWT library verifies a pool token: point it at
ISSUER/.well-known/jwks.json, restrictalgorithmsto["EdDSA"], and pinissuer+audience. (Node:jose'screateRemoteJWKSet+jwtVerifywithissuer/audience; Go:github.com/coreos/go-oidcorgithub.com/lestrrat-go/jwx.) Remember the explicitUser-Agenton the JWKS fetch. - The DataK3 side is unchanged and identical in both paths: the app holds a
k3.editorclient-credentials token and talks Postgres wire to the bucket, exactly as in Four Datastores, One Bucket. The user'ssubis a filter, never a credential.
Test
Run live on 2026-09-05 against prod (org ihdiash, pool retail-vision, app retail-hq,
FQDN retail-hq-ihdiash-8080.ignite.dodil.cloud). What executed, and the real results:
# 1. the app is pool-attached and private
dodil ignite app get retail-hq -o json
# → "user_pool": "ihdiash/retail-vision", "public_invoke": false,
# "public_urls": ["retail-hq-ihdiash-8080.ignite.dodil.cloud"], "deployment_state": "deployed"
# 2. anonymous browser → 401 with a pointer (NOT a redirect)
curl -i https://retail-hq-ihdiash-8080.ignite.dodil.cloud/
# → HTTP/2 401 ; www-authenticate: Bearer
# {"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}
curl -i https://retail-hq-ihdiash-8080.ignite.dodil.cloud/healthz
# → HTTP/2 401 ; login_url "/.dodil/auth/login?return_to=%2Fhealthz" (return_to carries the path)
# 3. the gateway's login endpoint starts a PKCE S256 authorization request
curl -i ".../.dodil/auth/login?return_to=%2F"
# → HTTP/2 303 → https://appid.dodil.io/ihdiash/retail-vision/authorize
# ?response_type=code&client_id=ingress
# &redirect_uri=…%2F.dodil%2Fauth%2Fcallback&state=…&code_challenge=…&code_challenge_method=S256
# set-cookie: __dodil_txn=v1.poolc1.…; Path=/; Max-Age=600; HttpOnly; Secure; SameSite=Lax
# 4. logout clears the host-only session cookie family
curl -i ".../.dodil/auth/logout"
# → HTTP/2 303 → / ; set-cookie __dodil_session, __dodil_session.0..3 all Max-Age=0
# 5. PLATFORM principal reaches the SAME login-required app
dodil ignite invoke retail-hq --timeout 45s
# → HTTP Status: 405 {"detail":"Method Not Allowed"} ← FastAPI's, i.e. the request got IN
# 6. the callback allowlist is enforced by the pool
curl -s "https://appid.dodil.io/ihdiash/retail-vision/authorize?…&redirect_uri=https%3A%2F%2Fnot-allowlisted.example.com%2F…"
# → HTTP/2 400 {"error":"redirect_uri is not in the pool's allowlist (settings.redirect_uris)"}
dodil appid settings get retail-vision -o json
# → {"redirect_uris":["https://retail-vision-hq-…/.dodil/auth/callback","https://retail-hq-…/.dodil/auth/callback"]}
# 7. the pool is a real OIDC-shaped issuer
curl -s "https://appid.dodil.io/ihdiash/retail-vision/.well-known/openid-configuration"
# → grant_types password|refresh_token|authorization_code ; alg EdDSA ; PKCE S256 ;
# token_endpoint_auth_methods_supported ["none"]
curl -s "https://appid.dodil.io/ihdiash/retail-vision/.well-known/jwks.json"
# → {"keys":[{"alg":"EdDSA","crv":"Ed25519","kid":"21053f8e…","kty":"OKP","use":"sig","x":"E1oZAzx…"}]}Confirmed end to end on 2026-09-05: a pool-attached, public_invoke: false app 401s an
anonymous caller with a machine-readable login_url; the gateway's own login endpoint issues a
PKCE S256 authorization request with client_id=ingress and the app's /.dodil/auth/callback
redirect URI, sealing transaction state into a 600-second HttpOnly; Secure; SameSite=Lax
cookie; logout clears the chunked host-only __dodil_session family; the pool rejects an
unlisted redirect_uri with a 400 naming settings.redirect_uris; and — the additive model,
proven — a platform principal reached the very same app and got a 405 from the application
itself. dodil appid settings set replacing the entire settings object is CLI-documented and is
how we broke our own login once.
Not re-run today (verified 2026-09-04, unchanged since): the Path-B signup / password-grant /
refresh round trip and the InvalidAudienceError on a wrong aud. The pool's role catalog
(approver → invoices:read, invoices:approve) was re-read live on 2026-09-05 and is intact.
Not independently re-verified this session: the exact byte-level contents of
X-Dodil-User / X-Dodil-User-Jwt / X-Dodil-Auth-Source as seen inside a handler — those are
Ignite v1.0.0's documented gateway contract and were observed during the retail-vision build; if
you are wiring a handler, log the three headers once on first deploy.
Conclusion
Authentication used to be the part of a DODIL app you had to write. It isn't any more — it's a
field. Attach a user_pool and the gateway runs PKCE, seals the cookie, refreshes the token,
verifies the JWT and hands your handler an identity it can simply trust; leave it off and the
app serves service accounts exactly as before. The two are additive, so "humans must log in"
never means "my CI can't call it", and X-Dodil-Auth-Source tells you which one you're serving.
The JWKS-verification code that used to be the whole integration is still correct — for an app
that is not behind the gateway, or a machine caller holding a pool token — and iss and
aud are still both mandatory there. It just isn't what an Ignite-hosted app should be writing.
Identity is only half of it. Once your app trusts who the user is, it needs what they can do — roles, permissions, and per-tenant scope. That's the companion post: App Authorization on DODIL: Roles, Permissions, and Tenants with dodil-appid.