> Configure authentication methods for your Penombre instance. Penombre supports multiple authentication methods powered by Better Auth.
> Source: https://orochibraru.com/penombre/canary/docs/authentication · Site index: https://orochibraru.com/llms.txt

# Authentication

Configure authentication methods for your Penombre instance. Penombre supports
multiple authentication methods powered by
[Better Auth](https://www.better-auth.com/).

## Auth secret

`AUTH_SECRET` is required and used to sign session tokens. Generate a secure
random value:

```bash
openssl rand -hex 32
```

> **Danger** — never reuse the same secret across environments. Rotating the
> secret invalidates all existing sessions.

## Email and password

Enabled by default. Users sign in with an email address and a password.

| Variable              | Description                   | Default |
| --------------------- | ----------------------------- | ------- |
| `ENABLE_EMAIL_SIGNIN` | Enable email/password sign-in | `true`  |
| `MIN_PASSWORD_LENGTH` | Minimum password length       | `8`     |

### Email verification

If SMTP is configured, Penombre sends a verification email on signup with a link
the user must click before they can sign in. Without SMTP, email verification is
skipped.

| Variable        | Description          | Default             |
| --------------- | -------------------- | ------------------- |
| `SMTP_ENABLED`  | Enable SMTP          | `false`             |
| `SMTP_HOST`     | SMTP server hostname | Required if enabled |
| `SMTP_PORT`     | SMTP server port     | `587`               |
| `SMTP_USER`     | SMTP username        | Required if enabled |
| `SMTP_PASSWORD` | SMTP password        | Required if enabled |
| `SMTP_FROM`     | Sender address       | Required if enabled |
| `SMTP_SECURE`   | Use TLS              | `false`             |

Set `SMTP_SECURE=true` for port 465 (implicit TLS) and `false` for port 587
(STARTTLS).

### Changing or verifying an address

**Account → Account details** shows whether the address is verified. **Verify**
mails a code to it; entering the code marks it verified. **Change** takes two
codes: one sent to the current address, then one sent to the new address. The
account moves only once both are entered, so a session alone cannot move an
account to another mailbox. The mobile app runs the same steps.

Both need SMTP: without it the buttons are off and the address stays as it is.

### Forgot password

**Forgot your password?** on the sign-in page mails a link to set a new one,
when password sign-in is on and mail is configured. The link lands on
`/auth/reset-password`, expires after an hour, works once, and signs out every
other session of that account. The new password follows the same rules as
everywhere else (minimum length, and the strong-password setting). The page
answers the same way whether or not the address has an account. Without SMTP it
says so and points to an admin.

## OAuth

Penombre supports any **OIDC-compliant** provider (Google, GitHub, Authentik,
Pocket ID, etc.). A provider can be declared two ways: in the environment, or in
**Admin → Settings → OAuth providers**. Both end up in the same list on the
sign-in page.

### From the admin UI

**Admin → Settings → OAuth providers** → **Add a provider**. You give it an id,
a display name, the client id and secret, and the discovery URL; the card shows
the redirect URI to register with the provider, and a copy button for it.

Three things worth knowing:

- **No restart needed.** Saving a provider reloads the auth layer, and another
  app instance sharing the database picks it up on its next OAuth request.
- **The id is permanent.** It is stored on every account that signs in through
  the provider, so it is read-only once saved. To change it, add a new provider
  and remove the old one — people will have to link their account again.
- **The secret is never sent back to the page.** Editing a provider leaves the
  secret field blank; leave it blank to keep the stored one.

A provider declared in the environment appears here read-only, marked _from the
environment_ — `config.ts` owns those, and the UI will refuse to save a stored
provider under the same id. Removing or disabling a provider that is somebody's
only way in is refused, exactly like the other
[sign-in methods](#which-methods-may-be-turned-off).

With no `ENABLE_OAUTH_SIGNIN` in the environment, having an enabled provider is
what turns OAuth sign-in on; setting the variable takes that decision back.

### Provider configuration

Each provider is configured with the naming pattern
`OAUTH_<PROVIDER>_<SETTING>`, where `<PROVIDER>` is an uppercase identifier of
your choice. Declaring one is enough to turn OAuth sign-in on —
`ENABLE_OAUTH_SIGNIN` only has to be set to force it either way.

| Variable                         | Description            | Default                |
| -------------------------------- | ---------------------- | ---------------------- |
| `OAUTH_<PROVIDER>_CLIENT_ID`     | OAuth client ID        | Required               |
| `OAUTH_<PROVIDER>_CLIENT_SECRET` | OAuth client secret    | Required               |
| `OAUTH_<PROVIDER>_DISCOVERY_URL` | OIDC discovery URL     | Required               |
| `OAUTH_<PROVIDER>_ENABLED`       | Enable this provider   | `true`                 |
| `OAUTH_<PROVIDER>_PRETTY_NAME`   | Display name in the UI | Provider name          |
| `OAUTH_<PROVIDER>_PKCE`          | Use PKCE               | `true`                 |
| `OAUTH_<PROVIDER>_SCOPES`        | Comma-separated scopes | `openid,profile,email` |

The discovery URL must point to the provider's
`/.well-known/openid-configuration` endpoint. Penombre auto-discovers
authorization, token, and userinfo endpoints from it.

### Example: Pocket ID

```bash
ENABLE_OAUTH_SIGNIN=true

OAUTH_POCKET_ID_CLIENT_ID=05a0dd79-...
OAUTH_POCKET_ID_CLIENT_SECRET=U8QJvEK8...
OAUTH_POCKET_ID_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
OAUTH_POCKET_ID_PRETTY_NAME=Pocket ID
```

### Example: Google

```bash
ENABLE_OAUTH_SIGNIN=true

OAUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...
OAUTH_GOOGLE_DISCOVERY_URL=https://accounts.google.com/.well-known/openid-configuration
OAUTH_GOOGLE_PRETTY_NAME=Google
```

### Redirect URI

When registering your OAuth application, set the redirect URI to:

```text
https://<your-domain>/api/v1/auth/callback/<provider-name>
```

The `<provider-name>` is the lowercase, hyphenated version of `<PROVIDER>`. For
example, `OAUTH_POCKET_ID_*` becomes `pocket-id`, so the callback URL is:

```text
https://cloud.example.com/api/v1/auth/callback/pocket-id
```

## Skipping the sign-in screen

If OIDC is your only way in, the sign-in screen is one pointless click. Set
`AUTH_AUTO_REDIRECT_PROVIDER` to a provider name and Penombre redirects to that
provider as soon as someone lands on `/auth/sign-in` — the form is never
rendered.

```bash
ENABLE_OAUTH_SIGNIN=true
AUTH_AUTO_REDIRECT_PROVIDER=pocket-id

OAUTH_POCKET_ID_CLIENT_ID=05a0dd79-...
OAUTH_POCKET_ID_CLIENT_SECRET=U8QJvEK8...
OAUTH_POCKET_ID_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
```

The value is the **provider name**, i.e. the lowercase, hyphenated version of
`<PROVIDER>` — `OAUTH_POCKET_ID_*` becomes `pocket-id`, the same name used in
the callback URL.

| Variable                      | Description                     | Default |
| ----------------------------- | ------------------------------- | ------- |
| `AUTH_AUTO_REDIRECT_PROVIDER` | Provider to redirect to on load | /       |

> **Escape hatch** — `/auth/sign-in?form` always renders the sign-in form, even
> with auto-redirect on. Use it to sign in with email or a passkey when your
> provider is down or misconfigured, so a broken IdP can't lock you out of your
> own instance.

Sign-out still works, but if your provider keeps its own session you may be
signed straight back in. Log out of the provider too for a full sign-out.

## Adding people to an instance

Sign-in is **email first**: the address is entered on its own, and Penombre then
asks for whatever that account actually needs.

- **A known account with a password** gets the password field.
- **A known account without one** — someone an admin invited who has not
  finished — is offered what can sign it in without a password: an emailed
  sign-in link or code, when those are on, and **Email me a link to set a
  password** when passwords are on. Receiving the email is what proves the
  address, just as the admin's invite link does.
- **An unknown address** is told to ask an admin. Whether an address can sign
  itself up is governed by **Admin → Settings → Sign-ups**, including an
  optional allow-list of email domains.

Admins add people under **Admin → Users**: enter an email (and optionally a
name), and the account is created with no credential at all. That absent
credential is what marks it as an invitation — the sign-in flow sees it and
routes the person to onboarding. Tick **Email invite** (available once SMTP is
configured) to have Penombre mail them the sign-in link.

With no mail server, or when the email could not be sent, the link exists
nowhere else: the page shows it in a dialog to copy and pass on yourself. It is
also how you finish an account on someone's behalf, a test account for instance:
open the link in a private window and choose its password.

The invite link leads to `/auth/onboarding`, where the person chooses a
password. While password sign-in is off, it offers no password: it sends them to
sign in with an emailed link or code instead.

Invite links expire after 7 days. An account that never finished onboarding (the
link expired, or was lost before it reached anyone) still shows in the list with
no password set; its row menu offers **Resend invite** (**Get invite link** when
no mail server is configured), which mints a fresh link and invalidates any
older, still-unused one for that account, so only one link is ever live at a
time.

There is deliberately **no way for an admin to set someone's password**. A
password a second person has chosen and passed along is a password that lives in
whatever channel carried it, and its owner believes it is theirs alone.

> Email-first sign-in does reveal whether an address has an account here, which
> a combined email-and-password form does not. That is the accepted trade of
> every email-first flow; better-auth's rate limiter caps how fast the lookup
> can be walked.

On their first sign-in, everyone gets a short walkthrough to pick an accent,
typeface, corner style and default layout. It can be skipped, and everything in
it lives in **Settings → Appearance** afterwards.

## Adding a password to an OAuth account

An account created through an OAuth provider has no password of its own. When
`ENABLE_EMAIL_SIGNIN` is also on, **Account → Security** offers **Set a
password** for such accounts, so the same person can sign in either way — handy
when the identity provider is down or unreachable.

The form only appears while the account genuinely has no password; once one is
set it becomes the ordinary **Change password** flow, which asks for the current
password first. Setting a password never detaches the OAuth provider — both
sign-in methods keep working.

If `ENABLE_EMAIL_SIGNIN` is `false`, the whole section is hidden and the
underlying action refuses: there would be no form to use the password on.

## Passwordless sign-in

Two optional methods let someone sign in without typing a password. Both are
turned on under **Admin → Settings → Sign-in methods**, both require working
SMTP (the toggles stay disabled until mail is configured), and both take effect
immediately.

| Method                    | What the person gets                           |
| ------------------------- | ---------------------------------------------- |
| **Emailed sign-in link**  | A one-time link that signs them in when opened |
| **Emailed one-time code** | A short code to type into the sign-in form     |

Both appear on the sign-in screen once the address has been entered, alongside
the password field. Neither can create an account: they only sign in an address
that already exists, so opening them does not open sign-ups.

> A sign-in link is a bearer credential — anyone holding the URL is signed in.
> Treat a forwarded link the way you would treat a forwarded password.

### When the email cannot be sent

If SMTP rejects the message, the sign-in screen now shows the reason the mail
server gave — `Could not send the sign-in email: <reason>` — rather than a
generic failure. The same reason is written to the server log. A wrong password,
an unreachable host and a refused sender address all look different, so start
there rather than assuming the method is broken.

Two things worth checking first:

- **The admin test button proves the values in the form, not the saved ones.**
  It builds a one-off sender from whatever is typed in, so a passing test and a
  failing sign-in mean the settings were never saved. Save, then test again.
- **A method that is off answers `403`.** The sign-in screen only offers methods
  that are on, so a failing button means the setting changed while the page was
  open: reload it.

A relay that needs no credentials is supported: leave the SMTP username and
password empty and no login is attempted.

## Two-factor authentication

Penombre supports TOTP two-factor: the six-digit codes an authenticator app
generates. It is always available — anyone can turn it on from **Account →
Security** — and an admin can make it compulsory.

Turning it on takes a password (to prove it is really you), then shows the
secret to add to an authenticator app together with a set of **backup codes**.
The codes are shown once and each works a single time; they are the way back in
if the phone is lost. Enrolment is only complete once a generated code has been
entered back, so a secret that never made it into an app cannot lock anyone out.

Signing in afterwards asks for a code at `/auth/two-factor`, which also accepts
a backup code. **Don't ask again on this device** remembers the browser so the
prompt is not repeated on every sign-in.

It is asked after a password, an emailed sign-in code and an emailed sign-in
link alike, so a mailbox alone never opens an account that has it; a link opens
the two-factor page and continues where it was going once answered. A passkey or
an OAuth provider is not followed by it: a passkey is already a second factor on
its own, and a provider runs its own.

### Requiring it for everyone

**Admin → Settings → Security → Require two-factor authentication** forces
enrolment. Anyone who has not set it up is redirected to **Account → Security**
on their next page load and cannot use the rest of the app until they have. The
setting shows how many accounts are still outstanding before you turn it on.

> Penombre bundles no QR encoder, so enrolment offers a tappable `otpauth://`
> link (which opens the authenticator app directly on a phone) and the secret in
> text for manual entry, rather than a QR image.

## Which methods may be turned off

Sign-in methods cannot be switched off in a way that locks people out. Saving
**Admin → Settings** is refused when either is true:

1. **Nothing would be left.** At least one method — email and password, a
   passkey, an emailed link, an emailed code, or an OAuth provider — has to
   remain.
2. **Accounts still depend on the one being removed.** Turning off email and
   password while some accounts have neither a passkey nor a linked OAuth
   provider would strand exactly those people, so the save is refused and the
   message names how many they are. The same applies to turning off passkeys
   while some accounts sign in only with one, and to removing an OAuth provider
   that is somebody's only way in.

Passkeys can therefore be the only method, once every account has registered
one.

The emailed link and code are exempt from the second rule: they authenticate an
address rather than a stored credential, so no account depends on them and
turning one off orphans nobody.

To get past a refusal, give the affected accounts another method first (or
delete them), then save again.

## Passkeys

Passkeys (WebAuthn/FIDO2) allow passwordless authentication using biometrics or
hardware security keys. Register one from **Account → Security**, and sign in
with it from the **Sign in with a passkey** button.

They are on by default. Turn them off with the **Passkey** row under **Admin →
Settings → Sign-in methods**, which also shows how many accounts have one, or
pin the setting from the environment:

| Variable                | Description            | Default |
| ----------------------- | ---------------------- | ------- |
| `ENABLE_PASSKEY_SIGNIN` | Enable passkey sign-in | `true`  |

When the variable is set it wins and the admin row is read-only; leave it out
and the admin UI decides. The switch applies immediately, with no restart: while
it is off the server refuses both signing in with a passkey and registering a
new one, and the sign-in page stops offering the button. Existing passkeys are
kept, and can still be listed and deleted.

Passkeys work with:

- Platform authenticators (Touch ID, Face ID, Windows Hello)
- Roaming authenticators (YubiKey, security keys)

> **Warning** — a passkey is bound to the hostname of your `ORIGIN`, which is
> what WebAuthn calls the relying-party ID. Three consequences:
>
> - Changing `ORIGIN` to a different hostname invalidates every existing
>   passkey; users have to register again.
> - `ORIGIN` must be the hostname people actually browse. Registering at
>   `http://localhost:5173` and then signing in at `http://192.168.1.10:3000`
>   fails, because the browser will not offer a credential issued for another
>   host.
> - Except on `localhost`, WebAuthn requires HTTPS. Behind a reverse proxy,
>   `ORIGIN` must be the public `https://` URL — see
>   [Reverse proxy](https://orochibraru.com/penombre/canary/docs/reverse-proxy).

Registration is not gated on how recently you signed in. Better Auth's default
is to refuse a passkey enrolment on a session older than a day; on a drive
people stay signed into for weeks that rejected essentially everyone, so
Penombre turns that freshness check off for this one endpoint. The challenge is
still bound to the session that asked for it, so nobody can enrol a passkey for
somebody else's account.

## Preferred sign-in method

Each account can pick the method the sign-in page offers first, under **Account
→ Security → Preferred sign-in method**. Only methods that are enabled on the
instance and usable by that account are listed: a passkey once one is
registered, a password once one is set, the emailed link or code when the admin
has enabled them and mail works.

After a successful sign-in, the browser remembers the account's email address.
The next visit to the sign-in page fills it in and looks the account up on its
own, then:

- **Passkey preferred**: the passkey prompt opens straight away, and only the
  passkey button is shown, to try again. A browser that refuses a prompt nobody
  clicked for (Safari, iOS) or a cancelled prompt just leaves that button.
- **Password, emailed link or code preferred**: that method is shown first. An
  email is never sent on page load; it still takes a click.
- In both cases **More ways to sign in** reveals the account's other methods.

**Use a different address** forgets the remembered address. With no preference,
or no remembered address, the page behaves as before. A preference that stops
being usable (the admin turns the method off, the last passkey is deleted) is
treated as no preference, so it can never lock anyone out.

## API keys

API keys provide programmatic access to the Penombre API. Users can create and
manage API keys from the settings page.

Keys are sent via request headers:

```bash
# Preferred
curl -H "x-api-key: pen_..." https://cloud.example.com/api/v1/storage/files

# Alternative
curl -H "Authorization: Bearer pen_..." https://cloud.example.com/api/v1/storage/files
```

API keys are rate-limited to **100 requests per minute** in production. Each key
tracks its own request count and automatically refills.

The same key signs a WebDAV client in, as the password of HTTP Basic, on `/dav/`
only. See [WebDAV and sync](https://orochibraru.com/penombre/canary/docs/webdav).

## Signing in a desktop app

The Penombre Sync desktop app never asks for your password. It shows a short
code and opens **/auth/device** in your browser, where you confirm the code
matches and choose **Approve** or **Deny**. Sign in first if the browser is not
already; you land back on the approval page afterwards. Two-factor, passkeys and
OpenID Connect accounts all work, because the browser does the signing in.

An approved app creates its own API key, named after the computer, and keeps it
in the system keychain. It appears under **Settings → API keys**; revoking it
there disconnects that computer. Codes expire after ten minutes and work once.

## Exporting your data

**Account → Security → Export your data** offers two downloads:

- **Files (.zip)**: everything at the root of your drive, zipped by the same
  worker job the bulk-download button uses.
- **Account data (.json)**: your profile, preferences and activity history.

Both are a same-origin download, not a page fetch buffered in memory, so a large
drive streams straight to disk.

## Deleting your own account

**Account → Security → Danger zone** lets you delete your own account.
Confirmation asks for your password; an OAuth-only account with no password is
instead let through only while its session is still fresh (signed in within the
last 24 hours), so sign out and back in first if it refuses.

Two things block a self-deletion outright, with a message explaining which:

- **You are the instance's only administrator.** Promote someone else first.
- **You own a shared drive.** Delete it or hand it to another member first,
  otherwise its other members would lose it out from under them.

Deleting cascades your files, folders, share links, sharings, API keys, passkeys
and activity rows immediately; the bytes under `STORAGE_PATH` are cleaned up by
the same hourly sweep that follows an admin-initiated removal (see
[Admin → Users](https://orochibraru.com/penombre/canary/docs/admin#users)).

## Initial admin account

A fresh instance has **no accounts and no default credentials**. On first start
every URL redirects to `/auth/setup`, a one-off screen that creates the
administrator: email, an optional name, and a password you choose.

Once any account exists the setup screen redirects to sign-in and its action
refuses, so it cannot be used later to add a second "first" administrator.

> Earlier versions seeded `admin@example.com` / `Admin1234!` from `ADMIN_EMAIL`
> and `ADMIN_PASSWORD`. Those variables are **gone** — a published default
> password on an internet-facing instance is a vulnerability, not a convenience.
> Existing instances are unaffected: they already have accounts, so the setup
> screen never appears. Remove the two variables from your `.env`.

The one exception is [auth bypass](https://orochibraru.com/penombre/canary/docs/simple-mode): with no authentication at
all there is nobody to sign in, so a single credential-less owner is created to
own the files.

## Rate limiting

In production, all authentication endpoints are rate-limited to **100 requests
per 15 minutes per IP address**. This protects against brute-force attacks. Rate
limiting is disabled in development mode.

Invalid API keys lock an address out. After **20 invalid keys in 15 minutes**,
from an `x-api-key` or `Bearer` header or a WebDAV password, further invalid
keys from that address answer `429` for the next 15 minutes and are no longer
logged. Valid keys keep working from a locked-out address. Each attempt is
logged with its address and user agent, and the lockout once, so a `fail2ban`
filter on `Locked out` can ban the address at the firewall, and the user agent
tells you which client is still holding a revoked key.

This keys on the client address, so behind a reverse proxy set `ADDRESS_HEADER`
(see [Reverse proxy](https://orochibraru.com/penombre/canary/docs/reverse-proxy)). Without it every request arrives from
the proxy's address, and the log cannot tell your clients apart.

## No authentication at all

[Simple mode](https://orochibraru.com/penombre/canary/docs/simple-mode) can run with authentication switched off entirely
— `BYPASS_AUTH=true`, no sign-in screen, every visitor is the shared owner. Only
do this behind your own auth proxy or on a trusted network. See
[No sign-in at all](https://orochibraru.com/penombre/canary/docs/simple-mode#no-sign-in-at-all).
