Skip to Content
We are live but in Staging 🎉
Core Concepts

Core Concepts

The two planes

Dodil separates who builds the app from who uses the app:

Platform planeApp plane (AppID)
ServiceDodil IAMDodil AppID
Principalsorg users, service accounts, dk_ API keysyour app’s end users
Issuerid.dodil.ioappid.dodil.io/{org}/{pool}
A token can…drive Dodil products (K3, Ignite, Git, …)drive your app — nothing else

The planes never share an issuer, and nothing AppID mints is accepted by any Dodil data plane. Both sides verify tokens by iss and aud, so a token from one plane presented to the other fails closed.

Pool

The unit of app identity. Creating a pool mints an Ed25519 keypair and derives everything an OIDC relying party needs:

FieldExample
issuerhttps://appid.dodil.io/acme/my-app
jwks_uri{issuer}/.well-known/jwks.json
discovery{issuer}/.well-known/openid-configuration
audiencepool:my-app

A pool is rows plus a keypair — there is no per-pool server or realm. Key rotation (dodil appid pool rotate-keys) publishes the new key in the JWKS alongside the old one, so cached verifiers keep working.

Pool state is active, migrating, or migration_failed. While a user-store migration is in its locked phase, sign-in and admin writes answer 503 — retry after cutover.

Connections

A connection is how users prove who they are. Today exactly one kind is implemented:

  • local — email + password. argon2id hashing, timing-equalized verification, rate-limited attempts.

oauth:<provider> (Google, GitHub, …), generic oidc, saml, and anonymous are reserved names on the wire but not implemented yet — configuring them does nothing today.

Tokens

TokenFormNotes
Access tokenJWT, EdDSA (Ed25519)Short-lived (default 15 min; per-pool access_ttl_secs). Verify against the pool JWKS.
Refresh tokenOpaque, 256-bitRotates on every use. Default 30-day family lifetime (refresh_ttl_secs).
ID tokenJWTMinted when the code flow requests scope=openid; aud = your client_id, nonce echoed.
Reset / verify tokensOpaque, single-usePassword-reset links (30 min TTL) and email-verification links (3 days).

Access-token claims: iss, aud, sub, email, connection, app_roles[], permissions[], tenants (slug → roles map), tenant (when pinned), amr (["pwd"]), iat, exp.

Refresh rotation and reuse detection. Every refresh returns a new refresh token and invalidates the old one. Presenting an already-used token is treated as theft: the entire session family is revoked. A short, configurable grace window (refresh_reuse_grace_secs, default 10 s, max 60, 0 = strict) forgives the double-submit race — a genuine retry inside the window gets the same successor pair back — but never resurrects a session revoked by logout, password change, or an admin.

Roles, permissions, and the catalog

Each pool carries a role → permission catalog (dodil appid roles set my-app admin=users.write,billing.read editor=posts.write). Users hold app roles; at mint time the catalog expands them into a flat permissions[] claim. Your backend checks permissions — the catalog is how you rename or re-scope a role without touching every user.

App tenants

Tenants are sub-organizations inside a pool — workspaces, subsidiaries, departments. A user can belong to many tenants with different roles in each:

  • Tokens always carry the user’s tenants map (up to 32 memberships).
  • Passing tenant=<slug> at sign-in pins the token to one tenant and merges that tenant’s roles into the token’s permissions.
  • Non-membership fails exactly like a wrong password — tenant existence is never disclosed.

Tenant slugs are 1–63 chars of [a-z0-9-] and immutable after creation.

User stores

Where the pool’s users, sessions, and tenants physically live. Three options:

StoreWhat it isWhen
Managed (default)AppID’s control-plane PostgresZero setup — start here
Your K3 bucketTables in a K3  bucket you own (_appid_users, _appid_sessions, _appid_email_claims, _appid_tenants, _appid_tenant_members)You want to query/join your users like any other data
Your PostgresAny postgres:// DSN you bringCompliance / data-residency needs

Switch stores live with dodil appid user-store migrate: warm copy → short locked window (issuer answers 503) → verify counts → cutover → verified purge of the source (unless --keep-source). A count mismatch aborts and rolls back automatically.

Credentials never live in the pool record: a K3 store is addressed as k3+sa://<service-account>@host:port/bucket and AppID mints short-lived tokens for it on demand.

Settings, policies, branding

Three JSON documents on the pool, all editable via CLI or API:

  • Settingsallow_signup, require_email_verification, password_min_length (8–128), access_ttl_secs, refresh_ttl_secs, refresh_reuse_grace_secs, redirect_uris.
  • Policies — rate limits (per-email login 10/min, signup 60/min, recover 5/h, authorize 120/min per client IP) and cors_allowed_origins (exact-match origin allowlist for the JSON API).
  • Branding — logo, colors (light + dark), layout, fonts, copy, links, custom CSS for the hosted pages, plus an email block for the transactional mails. See the hosted login recipe.

Where the control plane lives

Pools and users are managed through the standard Dodil surface: console, dodil appid CLI, or REST under https://api.dodil.io/v1/appid/… — all authenticated with your platform identity, never with pool tokens. Requests carry no organization; the gateway derives your org from your token. See Auth and Access.