Sign-in and accounts
isb serve keeps its own users, so a platform needs nothing else to sign in
to it. People sign in with an email and a password, a passkey, or a provider
(GitHub, Google, any OpenID Connect provider), get a browser session, and join
orgs by invitation. Agents and scripts use API tokens instead (Agents and
MCP). This page is the operator's and user's view; the protocol
details (the OAuth flow, passkey verification, sessions, CSRF, rate limits,
every endpoint) are in the Identity API, and
roles and tokens in Users, roles and superadmins.
Everything lives in one SQLite file, <state>/isb.db (--state-dir, default
$XDG_STATE_HOME/isb), mode 0600 in a 0700 directory.
The first admin#
While no user exists, make one of two ways:
On the host, as the daemon's user:
isb user create you@example.com # prompts for the password twiceIt reads stdin's first line instead when stdin is not a terminal. The first user is always a platform admin and owner of the
defaultorg.Through the web: at startup the daemon writes a one-time setup token to
<state>/setup-token(0600) and logs where it is. Open/setup(the link/setup#TOKENcarries it in the fragment), orPOST /api/v1/auth/setupwith it, so whoever reaches the port first cannot claim the platform. The file is removed once setup is done.
Passwords#
- At least 12 characters (at most 1024 bytes).
- Hashed with argon2id (19 MiB, 2 passes, 1 lane: OWASP's recommendation).
- A failed login always answers
401 invalid email or password, whether the email is unknown, the password wrong, the account disabled or without a password, and costs one argon2 verification either way, so neither the message nor the timing says which half was wrong. - Changing a password (the Account page) needs the current one and ends every
other session.
isb user passwd EMAILsets one from the host and ends all of them. - Passwords never come from argv, where they would show in
psand shell history.
Signing in with GitHub, Google or OIDC#
Providers are configured in ~/.config/isb/serve.env (or flags), and need
ISB_PUBLIC_URL: their callback URL is built from it, and without it they
stay off.
ISB_PUBLIC_URL=https://isb.example.com
ISB_GITHUB_CLIENT_ID=Ov23li...
ISB_GITHUB_CLIENT_SECRET=... # or a secret, see below
ISB_GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com
ISB_GOOGLE_CLIENT_SECRET=...
ISB_OIDC_ISSUER=https://idp.example.com/realms/main
ISB_OIDC_CLIENT_ID=isb
ISB_OIDC_CLIENT_SECRET=...
ISB_OIDC_NAME=Keycloak # the button's label (default "SSO")
ISB_OPEN_SIGNUP=false # see "Who may sign in"
The client ids, issuer and name also have flags (--github-client-id,
--google-client-id, --oidc-issuer, --oidc-client-id, --oidc-name,
--open-signup). Client secrets are never flags (argv shows in ps).
When ISB_*_CLIENT_SECRET is unset, isb reads a secret of the same name from
the default org's secret store, at each sign-in (so rotating it needs no
restart):
isb secret create ISB_GITHUB_CLIENT_SECRET # value on stdin; org default
At startup the daemon logs each provider's callback URL, or why a provider is off (no secret, no public URL, an http issuer).
| Provider | Callback URL to register | Scopes |
|---|---|---|
| GitHub | <public-url>/api/v1/auth/oauth/github/callback |
read:user user:email |
<public-url>/api/v1/auth/oauth/google/callback |
openid email profile |
|
| OIDC | <public-url>/api/v1/auth/oauth/oidc/callback |
openid email profile |
- GitHub: Settings, Developer settings, OAuth Apps, New OAuth App.
Homepage URL: the public URL. Authorization callback URL: the row above
(GitHub allows exactly one). Generate a client secret. isb reads
/userand/user/emailsand uses only the primary address, and only if GitHub has verified it. A GitHub App's OAuth credentials work the same way (give it the Email addresses: read account permission). - Google: Google Cloud console, APIs & Services: configure the OAuth
consent screen (External or Internal, scopes
openid,email,profile; while it is in Testing, add the test users), then Credentials, Create credentials, OAuth client ID, type Web application, with the callback above under Authorized redirect URIs (no JavaScript origins needed). - Generic OIDC (Keycloak, Authentik, Okta, Entra ID, Zitadel, Dex...): a
confidential client using the authorization code flow, with the callback
above as its redirect URI.
ISB_OIDC_ISSUERis the issuer exactly as the provider's/.well-known/openid-configurationstates it. The provider must putemailandemail_verifiedin the ID token or answer them at userinfo.
The issuer and every endpoint must be https (http is accepted only on loopback, for local providers and tests). The flow uses PKCE, a nonce and a browser-bound state cookie; see the flow.
A failed sign-in lands on /login?error=CODE, and the login page explains
the code in plain words (unverified_email, signup_closed,
invitation_mismatch, account_disabled, ...; the full list is in the
Identity API).
Who may sign in#
In order:
- An identity already linked (same provider and subject) signs its user in, whatever email it reports now.
- Otherwise, a verified email that matches a user links the identity to that user and signs them in.
- Otherwise, with a verified email, an account is created (no password,
name from the provider) only if the email has a pending invitation,
or
ISB_OPEN_SIGNUP=true. Every pending invitation for the address is accepted. An invitation token passed to the sign-in must be for that same address. After the first admin, accounts come by invitation unless open sign-up is on, as in Dokploy.
An unverified email never links and never creates an account. Disabled users are refused. Before first-run setup, nobody signs up through a provider.
Identities are recorded per provider (github, google, and oidc:<issuer>
for the generic one, so pointing it at another issuer cannot match old
subjects).
Linking and unlinking#
A signed-in user adds a provider from the Account page: the identity is linked
to them (refused with identity_taken if it belongs to someone else) without
a new session, and removed there too. Nobody removes their last way in: a
password, an identity and a passkey each count, and the last one left is
refused. Changing ways in needs a browser session, not an API token.
Passkeys#
Passkeys work once ISB_PUBLIC_URL is set: the relying party is its host and
the origin its scheme, host and port. It must be https or
http://localhost, and a domain name, not an IP; without it, passkeys are
off.
- Add one on the Account page while signed in; it asks the authenticator for a discoverable credential with user verification. ES256, EdDSA (Ed25519) and RS256 keys are accepted.
- Sign-in is usernameless: the login page's passkey button lets the browser offer the site's passkeys.
- A passkey whose signature counter goes backwards is refused as a likely clone.
isb verifies WebAuthn itself; the checks are listed under Passkeys in the Identity API.
Sessions#
Signing in sets the isb_session cookie (HttpOnly, SameSite=Lax, Secure
except over plain loopback HTTP, so http://localhost works in development).
A session ends 30 days after sign-in (--session-max-age) or after 7 days
unused (--session-idle), whichever comes first. Users list their sessions
on the Account page and end any of them; disabling a user ends theirs.
Invitations#
An org's owner or admin (or a platform admin) invites an email to the org with a role, from the web UI's Members page or the host CLI:
isb invite acme alice@example.com --role member # viewer, member, admin or owner
The invitation is valid for 7 days and single use; inviting the same address
to the same org again replaces it. It is shown once: the token, and a link
<public-url>/invite#<token> when --public-url is set (the token sits in the
fragment, which browsers never send to a server).
Accepting, at /invite#TOKEN:
- a new address: give a name and a password; the account is created and signed in (or sign in with a provider whose verified email is that address);
- an existing account: give its password (the invitation alone never signs anyone in as someone else), or accept while signed in as that address;
- accepting never lowers a role the user already has in the org.
Password resets#
/forgot-password (or POST password-reset/request) always answers the
same, whether or not the account exists. For an existing account it makes a
one-hour, single-use token (only the newest works) and delivers it. isb has
no mailer, so the daemon writes the reset link (or token) to its stderr,
that is its journal, and says so; an operator hands it over:
journalctl --user -u isb | grep -i reset
Opening /reset-password#TOKEN sets the new password and ends every session
of the user. An operator on the host can skip the link: isb user passwd EMAIL.
Managing users#
Platform admins manage users from the web UI's Platform page (or the
admin/users endpoints): disable and enable them, and make or unmake
platform admins. Nobody does either to themselves, and an enabled platform
admin always remains.
On the host, as the daemon's user, these commands open <state>/isb.db
directly, so they work before any user exists and while the daemon is down.
SQLite in WAL mode lets the daemon and the CLI share the file, and the daemon
reads sessions and tokens per request, so changes apply at once:
isb user create EMAIL [--admin] [--name N] password prompted twice, or stdin's first line
isb user ls [--json] users, flags, org memberships
isb user passwd EMAIL set a password, end their sessions
isb invite ORG EMAIL [--role member] prints the token, or the link with ISB_PUBLIC_URL
isb token create NAME [--org ORG] [--expires 90d] [--user EMAIL] [--scope S]...
isb token ls [--json]
isb token revoke ID...
Every change these commands make is recorded in the audit
log as local(uid N) on the cli surface, and so is
every sign-in, account, token, invitation, member and user change made over
HTTP.
With Cloudflare Access in front#
With Access configured, it stays the front door: the sign-in pages and
/api/v1/auth/* also need a valid Access assertion, and isb's own sign-in
applies behind it. An Access identity whose email is an isb user acts as that
user without signing in again (Reach isb serve remotely).