> Configure authentication methods for your Penombre instance. Penombre supports multiple authentication methods powered by Better Auth.
> Source: https://orochibraru.com/penombre/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).

### Forgot password

Password reset is available when SMTP is enabled. Users receive an email with a
reset link. If SMTP is disabled, the forgot-password flow is not available.

## 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** — an address an admin registered — goes to
  `/auth/onboarding` to choose a password. No admin ever sees it, and no mail
  server is involved.
- **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.

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**, 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.

### 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/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.

## 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/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/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.

## No authentication at all

[Simple mode](https://orochibraru.com/penombre/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/docs/simple-mode#no-sign-in-at-all).
