Teams and security
View as MarkdownLogin methods: passkeys, single sign-on and two-step verification
NebulaCtrl signs people in with a password, with a passkey, with a single-sign-on provider, or with a password or provider followed by a passkey or one-time code. This page covers what each does, how to set it up, and what it does and does not protect.
(The product calls a tenant a team; the code calls it a workspace. Everything below applies per user, per team or per instance as stated.)
Summary#
| Who configures | Default | |
|---|---|---|
| Password (argon2id) | each person | on |
| Forgot password (emailed reset link) | the instance administrator sets up SMTP; see Forgot password | off until email can be sent |
| Passkeys (WebAuthn): sign-in without a password, or the second step | each person, in Settings → Security | none registered |
| Two-step verification (TOTP) | each person, in Settings → Security | off |
| Require 2FA for a team | an Owner or Admin of that team | off |
| OpenID Connect / GitHub sign-in | the instance administrator (the first user), in Settings → Instance → Single sign-on | no provider |
| Open sign-up through a provider | the instance administrator, same place | off (invite only) |
| Trust a provider's MFA | the instance administrator, per provider | off |
Forgot password#
The login page offers Forgot your password? when the instance can send email: it needs NEBULACTRL_PUBLIC_URL (the link in the email is absolute) and either the instance SMTP relay (Settings → Instance) or an enabled email notification channel in one of the person's teams. The instance relay is tried first, then the person's teams in the order they joined them. Without that the login page says to ask a team admin, and the request endpoint answers 503 password_reset_unavailable.
- The link:
…/reset-password?token=ncr_…. It works once and expires after 30 minutes. Only its SHA-256 is stored. Asking for another link does not cancel the first one, but using any link ends all the others of that account. A weak password (under 10 characters) is refused without spending the link. - Same answer for everyone:
POST /api/v1/auth/password-reset/requestanswers202 {"status":"requested"}whether or not the address belongs to an account, and the email is sent in the background so response time does not tell them apart. Accounts that sign in only through an identity provider (no password) get no email, and an admin cannot generate a link for them either (409 no_password). - Rate limits: 10 requests per hour per client IP and 3 per hour per email address (answered
429the same way for real and unknown addresses), and at most 3 emails per hour per account (further requests are dropped silently). Using a link is limited to 10 attempts per minute per IP. - Everything ends with the reset: every browser session of the account is destroyed, including a sign-in waiting for its second factor, and every other open link of the account stops working. API tokens are not revoked. Two-step verification still applies at the next sign-in: a reset never bypasses it.
- Admin-generated link: an Owner or Admin opens Settings → Members → Reset password (
POST /api/v1/teams/{teamId}/members/{userId}/password-reset). The link is shown once and works without any SMTP setup. Admins cannot reset an Owner, nobody can generate one for themselves, and API tokens cannot generate links (a token must not be able to take over an account). - Operator link from the host: the only Owner who is locked out (no SMTP, no other admin) runs
nebulactrl-server reset-password <email>on the control-plane host. It prints the same one-time link (30 minutes, hash stored, nocreated_by), or with--generatesets a random password shown once, which also works for an account that has no password. It ends the account's sessions and auditsoperator.password_reset_link/operator.password_setas system actions (never the link or password). See Troubleshooting → Locked out. - Audit:
user.password_reset(written in each of the person's teams, with the person as actor) andmember.password_reset_link(the admin who generated one). Neither records the token. Requests for a link are not audited. - Test it: the unit and database tests use the in-process fake SMTP server of the notifications package; see
internal/notifications/passwordreset_test.go.
Two-step verification (TOTP)#
- Standard time-based one-time passwords: RFC 6238, HMAC-SHA1, 30-second steps, 6 digits, one step of clock-drift tolerance either way. Any authenticator app works (Google Authenticator, 1Password, Authy, Aegis …). There is no QR code: the setup screen shows the key to type or paste, and an
otpauth://link for the phone's own app. - Enrol: Settings → Security → Two-step verification → Turn on. The secret is generated on the server and becomes active only after the first code verifies, so abandoning the dialog changes nothing. The secret is stored encrypted with the instance secret key.
- Sign-in: after a correct password the server answers
401 two_factor_requiredand keeps the half-finished login in the session; nothing is signed in until the code is accepted (POST /auth/2fa). The pending login lasts 5 minutes and 5 wrong codes. - Replay and guessing: an accepted time step can never be accepted again. Five wrong codes in a row lock the account's second-factor check for 5 minutes, from any session, in addition to the per-IP limit of 10 attempts a minute.
- Recovery codes: turning 2FA on issues 10 single-use codes (
xxxx-xxxx-xxxx-xxxx, 80 bits each). Only SHA-256 hashes are stored. Each is burned the moment it is used. They are shown once; Settings → Security can replace all of them (that needs a current code). When three or fewer are left the page says so. - Turning it off needs a current code (or recovery code) and, for accounts with a password, the password.
- A lost phone and no recovery codes cannot be fixed from the UI: there is deliberately no "reset my 2FA by email". An operator on the control-plane host clears it with
nebulactrl-server reset-2fa <email>(it removes the authenticator app, passkeys and recovery codes after a typed confirmation, ends the account's sessions and auditsoperator.2fa_resetas a system action); see Troubleshooting → Locked out.
Passkeys (WebAuthn)#
A passkey is a WebAuthn credential held by a device, a password manager or a security key. NebulaCtrl uses it two ways:
- Passwordless sign-in. The login page offers Sign in with a passkey (shown only where the browser supports WebAuthn). The browser lists the passkeys it holds for this site, the person verifies on the authenticator (fingerprint, face, PIN) and the session starts. Because the authenticator verified the person, the sign-in is already two factors: no code follows and the session counts as second-factor verified for "require 2FA".
- Second factor. After a correct password (or an identity-provider sign-in that is not trusted for MFA), an account with passkeys is asked for one on the second step (
401 two_factor_required, thenPOST /auth/2fa/passkey/optionsandPOST /auth/2fa/passkey). A code from an authenticator app or a recovery code still works from the same screen.GET /auth/2fasays which methods the pending login can use. The pending login lasts 5 minutes and 5 wrong answers, like the code step.
Rules:
- Register several and name them (Settings → Security → Passkeys): a laptop, a phone, a security key. Up to 20 per account. They can be renamed and removed. The list shows whether each is synced (backed up by a platform password manager) or this device only, when it was added and last used.
- What is required of the authenticator. The credential is discoverable (a resident key, so passwordless sign-in works) and user verification is required at registration and at passwordless sign-in. Attestation is not requested (
none): NebulaCtrl trusts any authenticator and records no hardware model. For the second step after a password, verification is "preferred", so a security key without a PIN still works. - Adding or removing needs the password again when the account has one (so a stolen session cannot plant a passkey). The last passkey of an account that has no password and no linked identity cannot be removed (
409 last_sign_in_method). - A passkey satisfies "require 2FA". Members of a team that requires 2FA can enrol a passkey instead of an authenticator app, in the setup screen or in Settings → Security. The team page counts them as enrolled.
- Recovery codes still apply. Adding the first passkey issues 10 recovery codes (shown once) when the account has no unused code left; they also exist for an account that has only passkeys. Settings → Security can replace them: with a current code, or with the password for an account that has passkeys and no authenticator app. Removing the last passkey (with no authenticator app) deletes the codes.
- Relying party. The RP ID is the host of
NEBULACTRL_PUBLIC_URL(without port) and the allowed origin is its scheme and host (and port). Without a public URL (development only) both come from the request. Passkeys are bound to that host: changing the public hostname makes existing passkeys unusable (they are not deleted; the sign-in falls back to password and a code). WebAuthn needs HTTPS orlocalhostand a domain name: on an install opened by IP address (http://<ip>:8000) the passkey screens say so (422 passkeys_unavailable) and the buttons are hidden or disabled. - Clone detection. A passkey whose signature counter goes backwards is refused. Synced passkeys keep the counter at zero, which is accepted.
- Limits. Registration and sign-in ceremonies expire after 5 minutes and work once. The sign-in endpoints share the 10-per-minute-per-IP limit with the password login. The management endpoints need a browser session, never an API token or MCP.
- Not yet: conditional UI (autofilling a passkey in the email field), attestation or an allow-list of authenticator models, and an admin-initiated passkey reset from the dashboard (an operator on the host can run
nebulactrl-server reset-2fa <email>, which also removes the account's passkeys).
Requiring 2FA of a team#
Settings → Security → Require two-step verification (Owners and Admins; PUT /teams/{id}/security).
- Members of that team who have not turned on 2FA see a setup screen instead of the team's pages, and the API answers
403 two_factor_enrollment_requiredto everything except what enrolment needs:/me*,/auth/*, listing and switching teams, and invites. They can still switch to another team. - The person flipping the switch must already have 2FA (or a session verified by a trusted provider):
409 enrol_first. This prevents locking the only administrator out. - API tokens follow their creator. A token whose creator has not enrolled stops working in the team. The
/mcpendpoint authenticates tokens separately and is not gated by this rule today. - A session counts as verified when the user has TOTP or a passkey, or signed in through a provider that is trusted and asserted MFA (below).
Single sign-on#
Settings → Instance → Single sign-on (first block; visible only to the instance administrator, who also gets a Manage single sign-on link in Settings → Security). Two kinds of provider:
OpenID Connect (any issuer with discovery)#
Keycloak, Authentik, Okta, Microsoft Entra ID, Google, Auth0, Zitadel, Pocket ID, GitLab …
- At the identity provider create a confidential web client for NebulaCtrl.
- Register the callback URL NebulaCtrl shows (it is
https://<your public URL>/api/v1/auth/oidc/<provider id>/callback). The dialog shows it before you save: it reserves the new provider's ID (the optionalidofPOST /auth-providers), so the address never changes between registering and saving. SetNEBULACTRL_PUBLIC_URLfirst: without it NebulaCtrl derives the URL from the request'sHostheader, which is only acceptable for local development. - In NebulaCtrl choose Connect your identity provider, pick the provider (or Any OpenID Connect provider): name (button text), issuer URL (the base of
/.well-known/openid-configuration; the preset fills the pattern and highlights the{placeholders}to replace, and saving is blocked until none is left), client ID, client secret (encrypted; never shown again; leave empty on edit to keep it), optional extra scopes (openid email profileare always requested), optional allowed email domains. Test connection reads the issuer's discovery document without saving (POST /instance/sign-in-providers/test, instance admin only) and says what is wrong: unreachable host, untrusted certificate, no discovery document, an issuer that differs from the one entered, missing endpoints. Saving checks the same thing.
The flow is authorization code with PKCE (S256), a per-attempt state and nonce bound to the browser session, and the ID token's signature, issuer, audience, expiry and nonce are verified against the issuer's keys. If the ID token carries no email, the userinfo endpoint is consulted.
GitHub#
Register an OAuth App on GitHub (or GitHub Enterprise Server: put its base URL in the provider's URL field), with the callback URL above, and add the client ID and secret. NebulaCtrl asks for read:user user:email and uses the primary verified email. A GitHub account's two-factor status cannot be verified, so "trust MFA" is never available for GitHub.
Step-by-step recipes for each provider (with the exact issuer format, console link and the field that takes the callback URL) are in Single sign-on setup.
How a person is matched to an account#
- By a previous link: the provider's stable subject (
sub; numeric id for GitHub) is looked up first. - Otherwise by verified email only: a provider-asserted email that is not marked verified never links to, or creates, an account. (Linking on an unverified email would let anyone who can claim an address at a lax provider take over the account that owns it.) "Verified" means the ID token's
email_verifiedis true, or, for Microsoft Entra ID, which never sends that claim, the optional claimxms_edov(the tenant admin has verified the email's domain) is true. The link is recorded and shown under Linked sign-in accounts, where the person can unlink it (never their last way in). - Otherwise a new account is created, but only when
- the person presents a team invite (the invite link carries a token through the sign-in round trip; an invite bound to an email must match the provider's verified email), or
- the instance administrator turned on Allow open sign-up (off by default); the new user then gets a team through the teams module.
Otherwise sign-in ends with
signup_disabled.
- Allowed email domains on the provider apply to every sign-in and sign-up through it; an account outside the list is refused with
domain_not_allowed.
Accounts created through a provider have no password (the stored hash is empty and matches nothing), so the provider is their only way in; there is no "set a password" screen for them yet. For that reason the last sign-in method of such an account cannot be unlinked.
Two-step verification after single sign-on#
By default a person who has TOTP enabled still gets NebulaCtrl's code step after the provider signs them in (the provider's own MFA is not verifiable by NebulaCtrl). Per provider you can switch on Trust the provider's MFA: when the ID token's amr claim contains mfa, otp or hwk, the code step is skipped and the session counts as verified for team requirements. Only enable it for an issuer that enforces MFA and sets amr honestly. It is off by default and unavailable for GitHub.
What this does not do#
- No QR codes, SMS or email codes. Authenticator-app codes and passkeys only.
- No SCIM, group-to-role mapping or just-in-time role assignment from claims. Roles are assigned in the team.
- No admin-initiated 2FA reset from the dashboard (an operator on the host has
nebulactrl-server reset-2fa, see the lost-phone note above). - The
/mcpendpoint is not subject to the team 2FA requirement (tokens authenticate there without the gate). - Logging out of NebulaCtrl does not log out of the provider, and a provider session ending does not end a NebulaCtrl session (sessions last up to 30 days, 7 idle).
Audit and storage#
Audited, in the same transaction as the change: auth.passkey.add, auth.passkey.rename, auth.passkey.remove (name and id only, never key material), auth.2fa.enable, auth.2fa.disable, auth.recovery_codes.regenerate, auth.identity.unlink, auth.provider.create|update|delete (never the secret; secretChanged only), auth.settings.update, team.require_2fa. Tables: user_passkeys (credential id, public key and sign counter as JSON, name; no secret), user_totp (sealed secret, last accepted step, failure counter), user_recovery_codes (hashes), auth_providers (sealed client secret), user_identities, auth_settings, and workspaces.require_two_factor.