# Authentication

> How people get accounts and sign in: registration modes, instance admins, email, LDAP and Active Directory, sessions, and the CLI's browser login

People sign in with a local password, with an LDAP or Active Directory account, or both. Scripts, the CLI, the desktop app and AI agents use access tokens. This page covers how accounts are created, how email is delivered, how instance admins are chosen and how sessions work.

## Registration

`[auth].registration` decides who can create an account:

| Mode | Who can register | Notes |
| --- | --- | --- |
| `"open"` (default) | Anyone who can reach the server, with the **Sign up** tab on `/login` | Invited people can also register from their invitation link. |
| `"invite_only"` | Only people with an invitation to an organisation, using the email address it was sent to | The **Sign up** tab is hidden and `POST /api/v1/auth/register` without an invitation returns 403. LDAP logins still create accounts. |

What happens on registration depends on whether email is configured:

- **With email:** Kribble sends a verification link (valid 24 hours). The account is created when the link is opened. Until then the username and email are only reserved.
- **Without email:** the account is created immediately, without verification.
- **From an invitation:** the account is created immediately, because the invitation proves the email address, and the person joins the inviting organisation.

Every new account gets a personal vault (an organisation of its own) with a space called **My Notes**. See [Organisations and access](../running-a-server/organisations-and-access.md).

:::callout{type="warning"}
`invite_only` needs a way in for the first person: an LDAP login, or an account created while registration was still `open`. And invitations to people without an account only arrive by email. With `invite_only` and no email or LDAP, nobody new can join.
:::

## Instance admins

An instance admin can open **Instance admin** in the user menu (`/admin`), list all users and organisations, promote and demote admins, and see the LDAP sources and system status. Being an instance admin does not give access to anyone's notes. [Organisations and access](../running-a-server/organisations-and-access.md) describes the admin panel.

There are three ways to become one:

1. **`[admin].emails`.** At every start of `kribble-web` or `kribble-builder`, each listed address that belongs to an account is promoted. An address without an account is logged as a warning and skipped, so after someone registers (or first signs in through LDAP), restart a service to promote them.
2. **The admin panel.** An instance admin promotes or demotes others under **Instance admin → Users**. Nobody can demote themselves, so there is always at least one admin.
3. **The first account.** While `[admin].emails` is empty and no admin exists, the next account created becomes an admin.

The list is promote-only. Removing an address doesn't demote anyone, and a person demoted in the panel whose address is still listed is promoted again at the next restart. The Users tab marks these people as set by `config.toml`. To remove an admin for good, take the address out of the list and demote them in the panel.

## Email

Kribble sends three kinds of email:

| Email | When | Link valid for |
| --- | --- | --- |
| Verify your email | Registration, when email is configured | 24 hours |
| Reset your password | **Forgot password** on the sign-in page | 1 hour |
| Join an organisation | An organisation member invites an address | 7 days |

Links point to `[server].frontend_url`, so set it to the server's public address. The sender is `Kribble <[email].from>`.

Choose a transport in `[email]` (all settings are in [Configuration](../running-a-server/configuration.md)):

:::tabs{group="email"}
::tab{label="Resend"}
```toml
[email]
provider = "resend"
from     = "kribble@example.com"   # on a domain verified in Resend

[email.resend]
api_key = "<resend-api-key>"
```
::
::tab{label="SMTP"}
```toml
[email]
provider = "smtp"
from     = "kribble@example.com"

[email.smtp]
host     = "smtp.example.com"
port     = "587"            # 465 = TLS, 587 = STARTTLS, 25 = none
username = "kribble@example.com"
password = "<smtp-password>"
# encryption = "starttls"   # inferred from the port when empty
```

Kribble authenticates with SMTP AUTH PLAIN and refuses to send the password over an unencrypted connection unless the server is `localhost`. A relay that needs no login takes an empty `username`.
::
:::

At startup, `kribble-web` logs the transport it chose, for example `[email] provider=smtp from=kribble@example.com`. `provider=none` means mail is only written to the log, as `[email] (no provider) would send to=… subject=…`.

Without email:

- New accounts are not verified.
- Password reset doesn't work. A person who forgets a local password needs help from you (see [Troubleshooting](../running-a-server/troubleshooting.md)).
- Invitations are still created. People who already have an account see them as a notification in Kribble. People without an account never receive the link.

A failed send is logged as `[email] failed to send to …`. For registration and password reset, the request then fails with an error. For invitations, the invitation is still created.

## LDAP and Active Directory

Add one `[[ldap]]` block per directory and set `enabled = true`. LDAP and local passwords work side by side.

### How an LDAP login works

1. The user types a username or email and a password on the normal sign-in page.
2. Each enabled source is tried in the order it appears in `config.toml`. Kribble binds with `bind_dn` (or anonymously), searches `user_search_base` with `user_filter`, and binds as the entry it found with the typed password. The first source where this works wins.
3. The entry must have `attr_email` and `attr_username`. Kribble finds the local account by email, then by username. If there is none, it creates one, with a personal vault, and no local password.
4. If the source has `group_search_base`, Kribble looks up the user's groups and applies `[[ldap.team_map]]` (see below).
5. If no source accepts the login, or the directory is unreachable, Kribble tries the local password. A directory outage logs `LDAP auth failed, falling through to local`, and accounts with a local password can still sign in.

Details:

- The filter must match exactly one entry. More than one counts as a directory error, and the login falls through to the local password.
- An existing local account that signs in through LDAP is tagged with the source's `name` (shown in the admin panel). The tag is never overwritten.
- The display name comes from `display_name_format` (when `attr_first_name` or `attr_surname` is set), else `attr_display_name`, else `cn`. It only fills an empty display name; Kribble never overwrites one.
- Each login opens a new connection. `timeout_seconds` (default 5) limits the connection and the searches.
- An enabled source without `url` or `user_search_base` stops startup. The log shows `LDAP: enabled sources=…` when the sources load.

### Examples

:::tabs{group="ldap"}
::tab{label="OpenLDAP or FreeIPA"}
```toml
[auth]
registration = "invite_only"   # recommended with LDAP

[[ldap]]
name              = "primary"
enabled           = true
url               = "ldaps://ldap.example.com:636"
bind_dn           = "cn=kribble-readonly,ou=service,dc=example,dc=com"
bind_password     = "<service-account-password>"
user_search_base  = "ou=people,dc=example,dc=com"
user_filter       = "(|(uid=%s)(mail=%s))"
attr_email        = "mail"
attr_username     = "uid"
attr_display_name = "displayName"

group_search_base      = "ou=groups,dc=example,dc=com"
group_member_attribute = "memberUid"   # posixGroup
user_group_attribute   = "uid"

[[ldap.team_map]]
group_dn = "cn=developers,ou=groups,dc=example,dc=com"
org      = "acme"
teams    = ["engineering"]
```
::
::tab{label="Active Directory"}
```toml
[auth]
registration = "invite_only"

[[ldap]]
name              = "corp-ad"
enabled           = true
url               = "ldaps://ad.corp.example.com:636"
bind_dn           = "CN=kribble,OU=Service Accounts,DC=corp,DC=example,DC=com"
bind_password     = "<service-account-password>"
user_search_base  = "DC=corp,DC=example,DC=com"
user_filter       = "(&(objectClass=user)(|(sAMAccountName=%s)(mail=%s)))"
attr_email        = "mail"
attr_username     = "sAMAccountName"
attr_first_name   = "givenName"
attr_surname      = "sn"

group_search_base      = "OU=Groups,DC=corp,DC=example,DC=com"
group_member_attribute = "member"   # holds member DNs
user_group_attribute   = "DN"

[[ldap.team_map]]
group_dn = "CN=Kribble Editors,OU=Groups,DC=corp,DC=example,DC=com"
org      = "acme"
teams    = ["editors"]
```

The group search matches the `member` attribute directly. Nested group membership is not resolved.
::
:::

### Groups and teams

With `group_search_base` set, each successful login searches for groups whose `group_member_attribute` equals the user's `user_group_attribute` value (or full DN, with `"DN"`), narrowed by `group_membership_filter` if set. Each found group DN is compared, case-insensitively, with the `group_dn` of the source's `[[ldap.team_map]]` entries. For every match, Kribble makes the user a member of the organisation `org` and of each listed team.

- The organisation and teams must already exist. Missing ones are skipped silently. Create them in Kribble first; the next login fills them.
- Team names are matched exactly.
- Sync only adds. Leaving a group doesn't remove anyone from an organisation or team; remove them in Kribble.
- New organisation members get no permissions (see [Organisations and access](../running-a-server/organisations-and-access.md)).
- A failed group lookup is logged (`LDAP: group lookup failed`) and the login still succeeds.

### Caveats

- **Local passwords outlive LDAP accounts.** Disabling someone in the directory doesn't stop a local password they already had. Accounts created through LDAP can't get one by password reset: **Forgot password** sends them nothing. To lock someone out completely, also remove them from your organisations in Kribble.
- **Accounts are not deleted** when they disappear from the directory. People keep their personal vault.
- **`tls_skip_verify`** turns off certificate checks for `ldaps://`. Use it for testing only.

The **Auth Sources** tab of the admin panel shows the loaded sources and their team mappings. The bind password is never shown. Changes need a restart.

## Sessions

| Credential | Lifetime | What it is |
| --- | --- | --- |
| Access token | 15 minutes | A JWT signed with `jwt_secret`, sent by the web app with each API request. |
| Refresh token | 7 days | A JWT signed with `refresh_secret`, in the `kb_refresh` cookie (HttpOnly, `Secure` outside dev mode, SameSite=Strict, path `/api/v1/auth`). Each use replaces it with a new one. If an old refresh token is used again more than 30 seconds after it was replaced, Kribble assumes it was stolen and signs the user out everywhere. |
| `kb_site_session` | 24 hours | Lets signed-in users open published sites that are limited to space members. |
| `kb_files` | 1 hour | Lets the browser load uploaded files at `/files/`. |
| Access token `kb_…` | until revoked or expired | For the CLI, CI, the desktop app's sync and MCP. See [Organisations and access](../running-a-server/organisations-and-access.md). |

Changing or resetting a password signs that person out of every browser. Changing `jwt_secret` or `refresh_secret` signs everyone out.

Sign-in is rate limited per client address: 10 requests a minute to the login, registration and token-refresh endpoints. Each failed login also adds a delay for that username and address (1, 2, 4, 8, then 16 seconds), cleared by a successful login. The client address comes from `X-Forwarded-For` when the request comes through a trusted proxy (see `trusted_proxies` in [Configuration](../running-a-server/configuration.md)), so the reverse proxy must set it ([Installing](../running-a-server/installing.md)).

## The CLI's browser login

`kribble login` signs the CLI in without copying a token by hand, much like the OAuth device flow:

1. The CLI starts a login request (`POST /api/v1/cli/login`) and opens `https://<server>/cli-login?code=<code>` in the browser. Over SSH without a display, it prints the address to open instead.
2. The person, signed in to the web app, sees the computer's name and the requested scopes and chooses **All my organizations** (every organisation they belong to, including ones they join later) or a single organisation. They can narrow `read,write` down to `read`, and approve or deny.
3. Kribble creates an access token and encrypts it to a one-time key the CLI sent with its request. The CLI collects it by polling every 2 seconds. A request expires after 10 minutes and can be collected once.

The token is listed in the organisation's settings under **Access Tokens** (for **All my organizations**, in the personal vault's settings), where it can be revoked. The desktop app uses the same flow for **Settings → Servers → Connect**. For CI, create a token in the web app and use `kribble login --token` or the `KRIBBLE_URL` and `KRIBBLE_TOKEN` environment variables. See [The kribble CLI](../using-kribble/the-kribble-cli.md).
