Menu
Penombre docs

Authentication

Configure authentication methods for your Penombre instance. Penombre supports multiple authentication methods powered by Better Auth.

Auth secret

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

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.

VariableDescriptionDefault
ENABLE_EMAIL_SIGNINEnable email/password sign-intrue
MIN_PASSWORD_LENGTHMinimum password length8

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.

VariableDescriptionDefault
SMTP_ENABLEDEnable SMTPfalse
SMTP_HOSTSMTP server hostnameRequired if enabled
SMTP_PORTSMTP server port587
SMTP_USERSMTP usernameRequired if enabled
SMTP_PASSWORDSMTP passwordRequired if enabled
SMTP_FROMSender addressRequired if enabled
SMTP_SECUREUse TLSfalse

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 providersAdd 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 environmentconfig.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.

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.

VariableDescriptionDefault
OAUTH_<PROVIDER>_CLIENT_IDOAuth client IDRequired
OAUTH_<PROVIDER>_CLIENT_SECRETOAuth client secretRequired
OAUTH_<PROVIDER>_DISCOVERY_URLOIDC discovery URLRequired
OAUTH_<PROVIDER>_ENABLEDEnable this providertrue
OAUTH_<PROVIDER>_PRETTY_NAMEDisplay name in the UIProvider name
OAUTH_<PROVIDER>_PKCEUse PKCEtrue
OAUTH_<PROVIDER>_SCOPESComma-separated scopesopenid,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

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

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:

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:

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.

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.

VariableDescriptionDefault
AUTH_AUTO_REDIRECT_PROVIDERProvider 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.

MethodWhat the person gets
Emailed sign-in linkA one-time link that signs them in when opened
Emailed one-time codeA 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:

VariableDescriptionDefault
ENABLE_PASSKEY_SIGNINEnable passkey sign-intrue

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.

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:

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

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: 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 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.

This guide lives in the project repo: edit it there, and this page follows within a day.