Skip to content

Authentication

BamDude ships with always-on authentication: every API endpoint is protected, the first boot walks you through creating an admin, and from there users sign in with passwords, optional 2FA, or OIDC single sign-on. This page is the single source of truth for the auth stack -- groups, sessions, MFA, SSO, rate limits, and recovery.


Overview

  • User accounts -- multiple users with unique credentials and per-user MFA settings.
  • Group-based permissions -- 80+ granular resource:action permissions, three default groups (Administrators / Operators / Viewers), arbitrary custom groups.
  • Sliding-session JWTs -- 1 hour access tokens, transparently refreshed via an HttpOnly rotating cookie so users don't get bounced mid-session.
  • Multi-factor authentication -- TOTP (authenticator apps), email OTP, and 10 single-use backup codes.
  • OIDC / SSO -- authorization-code flow with PKCE for any standards-compliant provider (Authentik, Keycloak, Pocket-ID, Google Workspace, ...).
  • Rate limiting -- per-username and per-IP sliding-window buckets on login + forgot-password.
  • Setup-gate + admin recovery -- fresh installs walk through a one-time setup; lost-all-admins is recoverable via a CLI without losing data.

Auth is always on

There is no "disable auth" toggle. Every endpoint requires a valid session or API key. API keys (X-API-Key or Authorization: Bearer bb_...) bypass JWT validation but still satisfy the same permission checks.


First-Boot Setup

On its very first boot BamDude knows it has no admin yet, so it locks down the API and shows a setup form.

  1. Open the BamDude UI. The setup wizard is rendered automatically.
  2. Enter the initial admin's username, password, and (optional) email.
  3. Submit. Setup-gate flips off; you are redirected to the regular login page and signed in.

While the setup gate is up, only three endpoints respond:

Endpoint Purpose
GET /api/v1/auth/status Is setup needed? Used by the UI to pick login vs setup.
POST /api/v1/auth/setup Create the initial admin.
GET /api/v1/system/health Liveness probe.

Every other call returns 503 {"detail": "setup_required"} until setup completes.

Don't expose a fresh container

The setup endpoint is unauthenticated by design (there is no admin yet to authenticate against). Only expose port 8000 publicly after you've completed setup, or run setup over a private network first.


Default Groups

Group Description Permissions
Administrators Full access All permissions
Operators Control printers and manage content Printer control, queue, archives, library
Viewers Read-only access View printers, archives, queue

Custom groups can mix and match permissions. Newly OIDC-linked users land in Viewers by default (configurable per provider).


Permission Categories

Permissions follow a resource:action pattern -- e.g. printers:control, archives:read. Endpoints declare the permission they need with RequirePermission(...) so the matrix is enforced consistently across REST, WebSocket, and Telegram surfaces.

  • Printers -- printers:read, printers:create, printers:update, printers:delete, printers:control, printers:files, printers:ams_rfid, printers:clear_plate
  • Archives -- archives:read, archives:read_own / archives:read_all, archives:create, archives:update_own / archives:update_all, archives:delete_own / archives:delete_all, archives:reprint_own / archives:reprint_all
  • Queue -- queue:read, queue:read_own / queue:read_all, queue:create, queue:update_own / queue:update_all, queue:delete_own / queue:delete_all, queue:reorder
  • Library -- library:read, library:read_own / library:read_all, library:upload, library:update_own / library:update_all, library:delete_own / library:delete_all, library:purge (skip trash, hard-delete immediately)
  • Inventory -- inventory:read, inventory:create, inventory:update, inventory:delete, inventory:view_assignments
  • Cloud -- cloud:auth (per-user Bambu Cloud sign-in + cloud-profile CRUD; no settings:read needed)
  • Settings -- settings:read, settings:update, settings:backup, settings:restore
  • Notifications -- notifications:read, notifications:update, notifications:user_email (gates the per-user email opt-in page)
  • Stats -- stats:read, stats:filter_by_user (filter dashboards by started_by / uploaded_by)
  • Users / Groups -- users:read, users:create, users:update, users:delete, groups:read, groups:create, groups:update, groups:delete

Ownership permissions

Use *_own permissions for users who should only modify their own uploads and queue items. Operators typically get *_all; Viewers get neither. *_all always implies *_own.

Cloud profiles are per-user

Each user has their own Bambu Cloud login -- signing in as User A doesn't affect User B's session. The single cloud:auth permission covers login, logout, and all cloud-profile CRUD; settings:read is not required.

Inventory vs AMS-assignment visibility

inventory:view_assignments shows what's loaded in each AMS slot on the Printers page without exposing the full inventory. Grant it on its own to operators who need to verify spool-to-slot mapping at a glance but shouldn't see purchase history, lot codes, or stock levels.

Ownership semantics: *_own vs *_all

Permission shape Effect
archives:delete_own Delete only archives you uploaded / started.
archives:delete_all Delete any archive, including ownerless ones. Implies *_own.
queue:update_own Edit only queue items you added.
library:update_all Rename / move / delete any library file.

Ownerless items. Some content has no owner -- e.g. archives created before authentication existed, prints triggered by an auto-virtual-printer, or webhook-uploaded library files. These require *_all to modify; users with only *_own see them as read-only.

Users in multiple groups inherit the union of all groups' permissions -- assignments are additive, not least-privilege-min.

Reads are ownership-split too

The archive / queue / library read routes enforce a read_own / read_all split, not the flat *:read flag. Built-in Operators and Viewers carry the *:read_all variant, so they still see the whole farm's prints, queue, and library -- BamDude is a shared farm. A custom group that only holds the legacy flat *:read is backfilled to *:read_own (fail-closed): it has to be explicitly granted *:read_all to see other users' rows. The flat archives:read / queue:read / library:read flags are kept purely as the frontend's download / preview UI gate -- the API no longer honours them for row visibility.


User Management

Settings -> Users -> Users tab. Visible to anyone with users:read. Mutating actions (create / update / delete) are admin-only -- holding the users:create / users:update / users:delete permission is no longer sufficient on its own. "Admin" here means User.is_admin: either the legacy role == "admin" or membership in the Administrators group. This admin gate stacks on top of the permission, so a non-admin operator who was merely granted users:update can't self-escalate by minting an admin account. API keys carry no user identity, so they never pass the admin gate -- user administration is unreachable via API key.

Creating users

  1. Click Add User.
  2. Fill in Username, Password (subject to the password policy), Confirm password, and tick one or more Groups.
  3. (Optional) Add an Email -- required for email OTP, password-reset by mail, and per-user print notifications.
  4. Create. The new user can sign in immediately.

When Advanced Auth via Email is enabled, the password field is replaced with an email field: BamDude generates a secure random password and mails it directly to the user. No admin ever sees the password, which is strictly stronger than handing one over in chat.

Editing users

Click the pencil on a user row. Username, email, password, and group memberships are all editable. Saving a password change stamps password_changed_at, killing every existing session for that user.

Deleting users

Click the trash icon. If the user owns content (archives, queue items, library files, started prints), BamDude prompts for the disposition:

Choice Effect
Delete user AND their items Hard-deletes archives, queue items, library files, and any other owned content. Cascades.
Delete user, keep items Removes the user; their content becomes ownerless and only *_all holders can modify it afterwards. Activity-tracking history (e.g. "Started by alice") is preserved -- the username is shown as-recorded, even though the user row is gone.
Re-assign to admin Transfers ownership of every owned row to the chosen admin in one transaction. Useful for offboarding employees.

You cannot delete yourself, and you cannot delete the last administrator -- the UI greys those out with a tooltip explaining why.


Group Management UI

Settings -> Users -> Groups tab. Each group shows its name, description, and a per-category count badge ("Printers ⅞", "Archives 9/9") so you can eyeball coverage at a glance.

Click Add Group (or pencil on an existing group) to open the full-page group editor:

  • Search bar filters the permission grid live by permission name or description.
  • Select all / Clear all bulk-toggle every checkbox at once.
  • Category checkboxes at each section header toggle every permission in that category in one click.
  • Per-category count badges ("5/7") update as you tick boxes.
  • Description supports plain text -- write what you actually intend the group to do, future-you will be grateful.

Creating, editing, or deleting a group -- and adding or removing a group member -- is admin-only: it requires Administrators-group membership (or the legacy admin role) on top of the matching groups:* permission, so an operator with only groups:update can't privilege-escalate through the group editor.

System groups (Administrators / Operators / Viewers) cannot be deleted, and their name and permission set can no longer be edited -- the API rejects any attempt to rename a system group or change its permissions (stripping the Administrators set would be a self-inflicted lockout). Their descriptions still edit freely. Custom groups can be created, edited, and deleted at any time; users left in only a deleted group end up group-less and lose all permissions until reassigned.


Advanced Auth via Email

Optional SMTP layer that enables passwordless onboarding, admin-triggered password resets, and per-user print notifications. Toggle independently of basic auth.

Self-service password reset does not need this toggle

Recovery needs a mail server, and that is all it checks. Configuring SMTP is enough --- see Self-service password reset. Until 0.5.6 recovery was tied to this switch, so an operator who set up SMTP, sent a test message and never guessed there was a second one got "Advanced authentication is not enabled" from a link the login page kept showing.

Configure SMTP

Settings -> Email tab.

Field Notes
SMTP host e.g. smtp.gmail.com, smtp.fastmail.com, your self-hosted Postfix.
SMTP port 587 for STARTTLS (most common), 465 for implicit TLS.
Use STARTTLS On by default for port 587. Off for 465 (already TLS).
Username / password App-specific password recommended for Gmail / Fastmail / Apple.
From address Sender address shown to recipients. Some providers require it to match the auth user.
External URL The reachable URL of your BamDude instance -- baked into reset / welcome email links. Has to actually resolve from the user's browser.

Click Test email before flipping the toggle on -- it sends a one-shot to your own admin address and surfaces the SMTP error verbatim if anything's wrong.

Built-in templates

Editable under Settings -> Email -> Templates:

  • Welcome -- new account with auto-generated password
  • Password reset link -- self-service recovery; carries a one-time link, never a password
  • Password reset -- admin-triggered reset; carries the new password itself
  • Two-Factor code -- email OTP delivery
  • Printer error -- per-user mail when their print errors out
  • Print complete / failed / stopped -- per-user lifecycle mails

Templates are i18n-aware (en + uk); each template carries a subject line and a body with substitution variables like {username}, {printer_name}, {archive_url}.

Self-service password reset

Available when SMTP is configured and local login is enabled. When it is not, the login page shows a plain "ask your administrator" line in place of the link, rather than a form that collects an address and silently fails --- and the admin recovers the account from the server console.

  1. User clicks Forgot your password? on the login page and enters their email address.
  2. The endpoint answers the same either way (anti-enumeration), and only sends anything if the address belongs to an active local account.
  3. The email carries a one-time link, good for 1 hour. Asking again invalidates the previous link.
  4. Opening it shows the Set new password form. The new password is subject to the password policy.
  5. On success the user is returned to the sign-in form and signs in with the new password. Every session that was signed in on the old one is signed out.

The account is untouched until the link is used

Requesting a reset changes nothing. Before 0.5.6 the request itself generated a new password and mailed it, which meant anyone who knew your address could rotate your password and lock you out of a session you were happily using --- without you ever seeing the message that did it. Now the password changes only when somebody proves they read the mail.

The link's token is stored as a hash only, so a copy of the database is not a set of working reset links.

Admin-triggered resets are a different flow: Settings -> Users -> Reset password mails the user a newly generated password, and that one does require the Advanced Auth toggle.

Per-user email notifications

When Advanced Auth is on, individual users gate notifications for their own jobs under Notifications in the sidebar. The toggle list:

  • Print started -- email when one of your jobs begins
  • Print completed -- success
  • Print failed -- HMS error / cancelled
  • Print stopped -- manual cancel

Requires the user to have an email address on file and the notifications:user_email permission (default for Administrators + Operators, off for Viewers). This is independent of the global notification system -- it only mails the submitter, not the whole farm.


LDAP / Active Directory

BamDude supports LDAP authentication for environments running Active Directory, FreeIPA, or OpenLDAP. Local accounts coexist with LDAP -- the local admin always works as a fallback if the directory is unreachable.

Configure

Settings -> Authentication -> LDAP tab.

Field Notes
Server URL ldaps://ad.example.com:636 (LDAPS) or ldap://ad.example.com:389 (StartTLS). Plaintext LDAP without StartTLS is rejected -- credentials must be encrypted on the wire.
Security StartTLS (upgrade plain to TLS on port 389) or LDAPS (TLS from byte one on port 636).
Bind DN Service-account DN used to search for users (e.g. CN=bamdude-svc,OU=Service,DC=example,DC=com).
Bind password Service-account password. Stored encrypted at rest when MFA_ENCRYPTION_KEY is set.
Search base Where to look (e.g. OU=Users,DC=example,DC=com).
User filter LDAP filter; {username} is substituted at login. AD: (sAMAccountName={username}). OpenLDAP / FreeIPA: (uid={username}).

Click Test connection before flipping Enable LDAP -- it does a dry-run bind + search and shows the raw error if anything's misconfigured.

Group mapping

Map directory groups to BamDude groups via a JSON object:

{
  "BamDudeAdmins": "Administrators",
  "BamDudeOps": "Operators",
  "BamDudeViewers": "Viewers"
}

Keys are LDAP group cn values (case-insensitive); values are BamDude group names. Both AD-style memberOf and POSIX-style memberUid are supported. Group membership is re-synced on every login -- demoting a user in AD takes effect at most one BamDude login later.

If no mapping is configured, LDAP users are auto-provisioned with no group memberships and have to be assigned manually.

Provisioning

Toggle Effect
Auto-provision On = first successful LDAP login auto-creates a local row tagged auth_source=ldap. Off = admins must pre-create the user first via the LDAP tab in the Create User modal (see below); unknown LDAP usernames are rejected on login.
Sync email on login The user's email attribute is overwritten from LDAP on every login (so AD changes propagate).

LDAP-provisioned users show an LDAP badge in the Users list. Their Change password button is hidden -- passwords live at the directory, not in BamDude. Admin-triggered password resets and self-service forgot-password are blocked for LDAP accounts with a clear "managed by LDAP" message.

Manual onboarding (LDAP tab)

When LDAP is enabled, the Create User modal in Settings → Users gains a Local / LDAP tab toggle. The LDAP tab is a debounced directory search (≥2 chars) that returns up to 25 matches via the service-account bind. Each row shows the directory's displayName / email / DN and is annotated Already provisioned for usernames that already exist as BamDude users (so a duplicate-click is impossible). Picking one and clicking Provision user re-resolves the username via the service bind and creates the BamDude row through the same code path the auto-provision login uses — group mapping, default-group fallback, and email sync apply identically.

Use this when Auto-provision is off but you still want to pre-create directory users one at a time without hand-editing the database.

Permission required: users:create (admin by default).

Local admin fallback

The local admin account always works regardless of LDAP status. If the directory server is down, LDAP logins fail with a clear "directory unreachable, retry or use local account" message; the local admin can still sign in and unblock things. Do not delete the last local admin -- that's your get-out-of-jail-free if AD ever goes sideways.

If a local user and an LDAP user share a username, the local account wins -- LDAP cannot silently override an existing local row.


User Activity Tracking

When you act under an authenticated session, BamDude records who did what and surfaces it on cards across the UI:

Activity Where it shows
Library file uploaded "Uploaded by username" badge on the file card.
Archive created from a print "Started by username" on the archive card + detail page.
Queue item added Username next to the queue row.
Print started (auto-dispatch / cloud / external) Tracked when the trigger had an authenticated user; shows on the printer card during the active print.

Tracking is automatic -- there is no privacy toggle. Historical attribution is preserved even when a user is later deleted (the username is rendered as-recorded, but no longer clickable). For team auditing add stats:filter_by_user to operator groups so they can pivot dashboards by started_by / uploaded_by.


Backup & Restore

Users and groups are included in the standard backup if you tick Include users and Include groups at backup time:

  • Group definitions + memberships are preserved in full.
  • Passwords are NOT included -- backups only carry username + email + group memberships, never the PBKDF2 hash. This is intentional: a leaked backup file shouldn't equal leaked credentials.
  • On restore, every user has an empty password. Admins must:
  • Set passwords manually for each restored user (Users page -> Edit), or
  • With Advanced Auth enabled, hit Reset password on each user to mail them a fresh password, or
  • Direct users to the Forgot password? flow if SMTP is configured.
  • TOTP secrets and OIDC bindings are included (encrypted at rest if MFA_ENCRYPTION_KEY is set on both source and destination).
  • API keys are NOT included -- regenerate them on the new install.

Plan the rollover during a maintenance window so users can re-set passwords without a queue of confused tickets.

BamDude uses a sliding-session model: short-lived access tokens, long-lived rotating refresh cookie.

Access tokens

  • TTL: 1 hour (was 24 h pre-0.4.0).
  • Carry jti + iat. Logout revokes the token's jti until natural expiry; password changes stamp users.password_changed_at, and tokens older than that timestamp are rejected as stale on every request.

Refresh tokens

  • Issued by /auth/login, /auth/2fa/verify, and /auth/oidc/exchange.
  • Stored as a SHA-256 hash in auth_ephemeral_tokens; delivered to the browser as the bamdude_refresh cookie -- HttpOnly, SameSite=Lax, Path=/api/v1/auth. JavaScript never sees it; non-auth endpoints never receive it.
  • Rotated on every use. POST /auth/refresh marks the old row used_at=now, mints a new row in the same family_id, and returns a fresh access token.
  • OWASP reuse detection. If a refresh token is replayed (i.e. used twice), BamDude collapses the entire family across every device. The user is forced back to the login page everywhere.
  • Except within a few seconds of its own rotation. A token re-presented inside a short grace window is treated as a race rather than a theft: the request is refused with a 401, but nothing is revoked and the cookie is left alone, so the tab that lost simply picks up what the winner already stored. Without this, two tabs refreshing at the same instant looked exactly like a stolen session and logged the user out of everything -- see Multiple tabs below.
  • Logout / password change / admin-initiated MFA reset revoke ALL refresh tokens for the user, signing out every device.

Remember-me

The login form has a "Remember me for 30 days" checkbox.

Mode DB row TTL Cookie lifetime
Default 24 hours Session cookie (cleared when browser closes)
Remember me 30 days Max-Age=30d -- survives browser restarts

Session lifetime ceiling (Session Policy)

Settings -> Users -> Session Policy. An admin ceiling on how long any login can live. BamDude's real session lifetime is the refresh-token TTL -- access tokens are 1 hour and auto-refresh -- so this caps the refresh TTL (and its cookie Max-Age) at login and on every rotation.

  • Presets: 24 hours / 7 days / 30 days, plus a custom hours field.
  • Range: 1 hour minimum, 720 hours (30 days) maximum -- the same hard ceiling as remember-me.
  • Default: 720 hours (30 days), so existing remember-me sessions survive the upgrade untouched.
  • Lowering it takes effect on existing sessions at their next refresh -- the shorter TTL is applied when the refresh cookie next rotates, not retroactively.

The card is read-only for users without settings:update.

Frontend behaviour

The frontend request() helper transparently retries 401s through /auth/refresh, promise-coalesced so a wave of parallel queries spawns exactly one refresh call. Coalescing extends across tabs via the Web Locks API: whichever tab takes the lock performs the refresh, and the others adopt the token it stored instead of asking again. Proactive renewal is also jittered, so tabs that logged in together do not all wake at the same instant. If refresh also fails, a global bamdude:auth-invalidated event clears React state and hard-redirects to /login. A visibility-change listener proactively revalidates /auth/me when a hidden tab regains focus.

Multiple tabs

Two or more tabs open on BamDude share one session, and they coordinate rather than compete.

Each tab schedules its own quiet renewal shortly before the access token expires. Left alone, every tab would compute the same instant from the same token and fire together -- and the server, which treats a second use of one refresh token as a stolen session, would collapse the family and log you out everywhere. With two tabs open this happened roughly once an hour and looked exactly like the session expiring for no reason.

Three things prevent it now:

  • A cross-tab lock. Whichever tab claims the Web Locks entry does the refresh; the others wait, then adopt the token it stored.
  • Jitter. Renewal times are spread by a few seconds so tabs that logged in together do not wake in lock-step.
  • A grace window on the server. A refresh token replayed within seconds of its own rotation is answered with a 401 and nothing else -- no revocation, no cleared cookie. A genuine replay, later, still collapses the family.

If your browser does not implement the Web Locks API, tabs fall back to in-tab coalescing plus the server-side grace, which covers the same race.

Secure is auto-detected from the request scheme. Behind a reverse proxy, set TRUSTED_PROXY_IPS (comma-separated) so BamDude reads the original X-Forwarded-Proto header.

# .env
TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2

For edge cases (e.g. TLS-terminating load balancer that doesn't set X-Forwarded-Proto), force the polarity:

AUTH_REFRESH_COOKIE_SECURE=true
AUTH_REFRESH_COOKIE_SECURE=false

Multi-Factor Authentication

2FA is per-user opt-in. Each user can enrol one or more factors from Settings -> Profile -> Two-Factor Authentication.

Factors

Factor How it works
TOTP Authenticator app (Google Authenticator, Aegis, Authy, 1Password, ...). Six-digit rolling code, generated from a Fernet-encrypted secret.
Email OTP One-time code sent to the user's email. Useful as a fallback when TOTP isn't practical.
Backup codes 10 single-use codes generated at enrolment. Shown once -- store them offline. Re-generate anytime to invalidate the old set.

Login flow with 2FA

  1. User submits username + password.
  2. Server verifies credentials, returns requires_2fa=true, a short-lived pre_auth_token, and a 2fa-challenge cookie.
  3. UI shows the 2FA picker (TOTP / email / backup code).
  4. User submits the code to /auth/2fa/verify.
  5. Server returns the access JWT + sets the refresh cookie. Login complete.

Encryption at rest

TOTP secrets and OIDC client secrets are Fernet-encrypted in the database. Backup codes are pbkdf2-hashed regardless. As of 0.4.4, encryption is on by default -- BamDude bootstraps a key automatically on first start, so a fresh install never silently writes plaintext secrets.

Key resolution order (first hit wins):

  1. MFA_ENCRYPTION_KEY environment variable -- the explicit pin (recommended for multi-host or multi-worker deployments where one key has to be shared).
  2. DATA_DIR/.mfa_encryption_key file (mode 0o600) -- single-host installs typically end up here.
  3. Auto-generated -- on first boot, BamDude creates a fresh Fernet key and writes it to .mfa_encryption_key. Atomic create with O_EXCL so the mode bits are right from the first byte; never world-readable.
# .env (optional -- only set this when you want to pin a specific key)
MFA_ENCRYPTION_KEY=<base64 32-byte Fernet key>

Generate a key

python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Status panel

Settings → Authentication → Security surfaces a live status card with five severity levels:

  • 🟢 Green -- key configured, every secret encrypted.
  • 🟡 Amber -- legacy plaintext rows still exist (will be re-encrypted on next write) or the key was auto-generated (back up DATA_DIR/.mfa_encryption_key so a future restore on a fresh host can decrypt secrets).
  • 🔴 Red -- decryption broken (key configured but cannot decrypt existing rows -- happens after a key rotation or a cross-deployment restore where the running install holds the wrong key). Recovery: restore the original key file or re-enrol affected users.
  • ⚫ Grey -- not configured at all and no encrypted rows exist.

Backup integration

.mfa_encryption_key is bundled into the backup ZIP alongside bamdude.db. Restore on a fresh host extracts the key before the database swap with chmod 0o600 -- so a self-contained backup keeps access to encrypted secrets without manual intervention. The restore aborts with a clear 500 if the key write fails (RO disk / EACCES) before the DB is replaced, so a live install can never end up with the wrong-key combination.

Legacy install upgrade path

Pre-0.4.4 installs that ran with MFA_ENCRYPTION_KEY unset have plaintext rows in the database. On the next startup after the upgrade, the auto-bootstrap generates a key and a one-shot migration re-encrypts those rows in place. Per-row transactions: a single corrupt row doesn't block the others, and the skipped count is surfaced on the status card so you can spot poison rows that need attention.

Admin-initiated reset

If a user loses their authenticator device, an admin can trigger a 2FA reset for that user from the Users page. The reset disables all factors and revokes every refresh token for that account, so any logged-in session is killed -- the user signs in fresh with just a password and re-enrols.


OIDC / SSO

BamDude supports OpenID Connect single sign-on against any standards-compliant provider.

Configure a provider

Settings -> Authentication -> OIDC Providers -> Add provider.

Field Notes
Display name Label on the login button ("Sign in with Authentik").
Issuer URL The provider's discovery URL base (e.g. https://auth.example.com/). Must be HTTPS.
Client ID From the provider's BamDude app registration.
Client secret Fernet-encrypted at rest when MFA_ENCRYPTION_KEY is set.
Scopes Default openid profile email. Add provider-specific scopes if needed.
Claim mapping Which OIDC claim maps to BamDude username / email.
Auto-create users Off by default -- new logins must match an existing local user by email. On = auto-create the user automatically (placed in the group set by Default group below).
Default group Group new auto-created users land in. Defaults to Viewers (read-only) for safety; pick a custom group for tenant-internal SSO setups where read-only is too restrictive. The picker is sourced from the live group list, so any custom group you create in Settings → Authentication → Groups is selectable here. If the chosen group is later deleted, new logins fall back to Viewers.

The login page renders an "Sign in with <provider>" button per configured provider, below the password form.

Declaring a provider in environment variables

For installs managed by a compose file, Helm or GitOps, where clicking through Settings once is not part of the deployment. Set these and the provider is created on startup and reapplied on every restart:

# Required — all four, or nothing is applied
BAMDUDE_OIDC_NAME=Authentik
BAMDUDE_OIDC_ISSUER_URL=https://id.example.com
BAMDUDE_OIDC_CLIENT_ID=bamdude
BAMDUDE_OIDC_CLIENT_SECRET=change-me

The optional variables carry the same defaults they have in the UI:

Variable Default
BAMDUDE_OIDC_ENABLED true
BAMDUDE_OIDC_SCOPES openid email profile
BAMDUDE_OIDC_AUTO_CREATE_USERS false
BAMDUDE_OIDC_AUTO_LINK_EXISTING false
BAMDUDE_OIDC_EMAIL_CLAIM email
BAMDUDE_OIDC_REQUIRE_EMAIL_VERIFIED true
BAMDUDE_OIDC_ICON_URL (empty)
BAMDUDE_OIDC_AUTOLOGIN false
BAMDUDE_OIDC_DEFAULT_GROUP (unset — falls back to the UI default)

Booleans accept true/1/yes/on and false/0/no/off.

An env-declared provider is read-only in Settings

Because the variables are reapplied on every boot, the provider is shown as read-only and the app refuses to change it there. An edit would be accepted and then silently undone at the next restart, which is worse than being told no.

Removing the variables disables the provider rather than deleting it. Accounts already linked to it keep their link and get it back if the variables return.

The failure modes are all quiet ones, so they are all loud here

  • A required variable left empty counts as unset, not as an empty secret.
  • Values are trimmed — a secret mounted from a file or written as a Kubernetes block scalar does not smuggle in a trailing newline that would only fail on the first sign-in attempt.
  • An unrecognised true/false value is refused with a log line instead of being guessed at, and the whole config is skipped: whatever was already running stays running.
  • BAMDUDE_OIDC_DEFAULT_GROUP is a group name, not an ID — IDs differ on every install. A name matching no group refuses the config rather than quietly falling back to Viewers.
  • A bad configuration never stops the app from starting.

Everything above is also documented in .env.example.

Hardening

  • PKCE S256 -- mandatory, non-negotiable.
  • State + nonce -- both verified on callback. The state token is atomically consumed, so replays fail.
  • JWKS verification -- ID tokens are signature-verified against the provider's published JWKS.
  • SSRF guards -- the issuer URL must be HTTPS and must not resolve to loopback, private (RFC 1918), or link-local addresses.

Autologin & disabling local login

For a team that lives entirely inside its IdP, BamDude can skip the password form:

  • Autologin -- a per-provider toggle (only one provider can carry it -- enabling it on one clears it on every other) that redirects unauthenticated visitors straight to that IdP's authorize URL on page load, as long as that provider stays enabled. A deep link is preserved across the SSO round-trip, so a bookmarked /archives/42 lands back where it started after login. The authorize-URL fetch is raced against a 5-second timeout; on timeout or error BamDude falls back to the normal login page and shows a banner explaining why autologin didn't fire.
  • Disable local username/password login -- a companion switch (Settings -> Users) that hides the password form entirely so only OIDC works. It is lockout-guarded: you can't turn it on unless at least one OIDC provider is enabled and your own account is OIDC-linked -- otherwise you'd lock yourself out.

Recovery when SSO is down

If your IdP is unreachable and local login is disabled, two escape hatches restore the password form: append ?fallback=local to the login URL (/login?fallback=local), or set the server-side env var BAMDUDE_LOCAL_LOGIN=true, which overrides the stored setting so a host admin can always sign in locally.

Self-signed CAs

If your provider runs behind a self-signed certificate (common for self-hosted Authentik / Keycloak), make the CA chain visible to BamDude's HTTP client. There is no dedicated OIDC_CA_BUNDLE_PATH env var — instead, mount the trusted root onto the system bundle the Python ssl module reads:

  • Container deploys: bind-mount your CA into /usr/local/share/ca-certificates/ and run update-ca-certificates in your image, or set the standard env vars SSL_CERT_FILE / REQUESTS_CA_BUNDLE to a PEM file mounted into the container.
  • Native installs: drop the CA into the OS trust store (/etc/ssl/certs/ on Debian/Ubuntu via update-ca-certificates).

These are the same knobs every Python HTTPS client respects — httpx (used for the discovery + token + JWKS fetches) reads them transparently.

Don't auto-link by email lightly

Auto-create + auto-link to existing local accounts means a compromised IdP can hijack any local user with a matching email. Leave both off unless you trust the provider as much as your local password hashes.

Microsoft Azure / Entra ID — custom email claim

Microsoft Entra ID (formerly Azure AD) doesn't ship the standard email claim or the email_verified flag — it puts the user identifier into preferred_username or upn and assumes verification on the IdP side. BamDude has two extra fields per provider for that case:

Field Effect
Email claim Which OIDC claim BamDude reads as the user's email. Default email. For Entra ID set to preferred_username or upn. Whitelist regex [a-zA-Z][a-zA-Z0-9_\-]{0,63} blocks log-injection / dynamic-claims-lookup attack vectors.
Require email_verified Default ON (refuses to log a user in unless the IdP marks their email verified). Entra ID never sends this flag, so for Entra ID flip it OFF.

There's a hard guard against the unsafe combo: auto_link_existing_accounts=true AND email_claim='email' AND require_email_verified=false is rejected at save time (and as a DB-level CHECK constraint on Postgres) — without that gate, any IdP that lets users self-register with an arbitrary email could silently hijack existing local accounts. Custom email claims (preferred_username, upn, etc.) bypass the verified-check requirement automatically because the claim semantics are different.

The form's "Require email verified" toggle is auto-disabled (greyed out) when email_claim != "email" — there's no email_verified to consult on a custom claim. The bonus shape control is two <datalist> autocomplete suggestions: email / preferred_username / upn so you don't have to type it.

Tested IdPs

BamDude's OIDC flow has been validated against PocketID, Authentik, Keycloak, Authelia, Google, and Microsoft Entra ID (Azure AD). Other standards-compliant providers should work — let us know if you hit edge cases.


Rate Limiting

Sliding-window buckets sit in front of password-bearing endpoints. Buckets are stored in the auth_rate_limit_events table -- no global lock, so legit users on the same network aren't held back by an attacker burning through codes elsewhere.

Endpoint Per-username Per-IP
POST /auth/login 10 / 15 min 20 / 15 min
POST /auth/forgot-password 3 / 15 min (per email) 10 / 15 min
POST /auth/forgot-password/confirm -- 10 / 15 min

Forgot-password records the attempt eagerly -- the endpoint always returns success (anti-enumeration), so the rate limit is the only thing pacing brute-force email guessing.

Behind a reverse proxy

If BamDude sits behind nginx / Caddy / Traefik / Cloudflare, set TRUSTED_PROXY_IPS so the rate limiter reads the original client IP from X-Forwarded-For instead of the proxy's IP -- otherwise every request shares the proxy's IP and the cap bites within a few logins.

# .env -- comma-separated, no spaces
TRUSTED_PROXY_IPS=10.0.0.1,172.16.0.1

Multi-hop chains (nginx -> Cloudflare -> BamDude) are handled by right-to-left resolution: BamDude walks X-Forwarded-For from the right and accepts the rightmost IP that isn't in the trusted set as the real client.

Single-host deploys

Leave TRUSTED_PROXY_IPS unset on a no-proxy install. BamDude falls back to the direct TCP peer IP, which is correct in that case.


Password Policy

Aligned with NIST SP 800-63B. Composition rules beyond a sane minimum are deprecated by NIST as low-value friction; BamDude follows that lead.

On create / change / reset:

  • At least one uppercase letter
  • At least one lowercase letter
  • At least one digit
  • Minimum 8 characters
  • Maximum 256 characters (sane upper bound to cap pbkdf2 cost)

No special-character requirement (dropped in 0.4.0.1 -- previously enforced, now considered noise that pushes users to predictable substitutions).

How the rules show up on the form

Every field where you set a new password --- first-boot setup, create user, edit user, change your own password, and the reset-link page --- lists the four requirements underneath and ticks them off in green as you type. They appear the moment you click into the field, so you can see what is wanted before the password is refused rather than after.

Every password field also has an eye at its right-hand end to reveal what you typed.

Fixed in 0.5.6

Before this, the requirements were nowhere on screen: the forms disabled their submit button on the rules, while the message naming the unmet one was written to fire when you pressed the button that rule had just disabled. A password that was merely too short produced a grey button and no explanation anywhere. Two forms were also checking for 6 characters while the API has wanted 8 and a character mix for a long time, so they accepted passwords the server then refused --- and the API itself never applied the 8-character floor when an admin created or edited a user, only on setup and password change.

How the rules show up on the form

Every field where you set a new password --- first-boot setup, create user, edit user, change your own password, and the reset-link page --- lists the four requirements underneath and ticks them off in green as you type. They appear the moment you click into the field, so you can see what is wanted before the password is refused rather than after.

Every password field also has an eye at its right-hand end to reveal what you typed.

Fixed in 0.5.6

Before this, the requirements were nowhere on screen: the forms disabled their submit button on the rules, while the message naming the unmet one was written to fire when you pressed the button that rule had just disabled. A password that was merely too short produced a grey button and no explanation anywhere. Two forms were also checking for 6 characters while the API has wanted 8 and a character mix for a long time, so they accepted passwords the server then refused --- and the API itself never applied the 8-character floor when an admin created or edited a user, only on setup and password change.

Other length caps across auth endpoints: email 254 (RFC 5321), username 150, forgot-password token 128.

Password change kills sessions

Changing your password (or having an admin reset it) stamps users.password_changed_at. Any access token with iat older than that timestamp is rejected as stale on the next request, and every refresh-token row for that user is revoked. Result: a password change instantly logs you out of every device, the way it should.


Admin Recovery

Two commands, for two different situations. Both run from a shell on the host, against the same database the server uses.

Run with the server stopped

Both the CLI and the server hold the SQLite WAL. Running them simultaneously can corrupt the database. Stop the server first.

docker compose stop bamdude     # OR: systemctl stop bamdude

Reset a password from the console

Use this when the account still exists but nobody can get into it --- a forgotten password on an install with no mail server, where self-service recovery does not exist at all. Whoever owns the machine has a shell on it, and that is the same person the reset email would have gone to.

# Who is there?
python -m backend.app.cli list_users

# Prompt for a new password (typed twice, never echoed).
python -m backend.app.cli reset_password --username admin

# Or have one generated and printed once.
python -m backend.app.cli reset_password --username admin --generate

# Second factor lost along with the password? Clear it too.
python -m backend.app.cli reset_password --username admin --clear-2fa
  • The new password goes through the same rules the web form applies, so the console cannot set one the account could never set again through the UI.
  • Every session signed in on the old password is signed out.
  • --clear-2fa removes TOTP, email OTP and backup codes. Without it, an admin who lost both their password and their authenticator still cannot get in.
  • LDAP accounts are refused with a message: their passwords live at the directory server, so setting one here would change nothing.
  • A deactivated account is reported as such --- the password is set, but the account still needs re-enabling before anyone can sign in with it.

Re-run first-boot setup

Use this when there is no admin account left at all --- the last one was deleted.

python -m backend.app.cli reset_admin

reset_admin clears the setup-complete flag so the next boot re-enters the first-boot setup form, where you create a new admin from scratch. All your existing data (printers, archives, queue, users, library) is preserved.

It refuses while any admin still exists

That is deliberate --- it would be a way to walk past a login you simply forgot. If an admin account is still there and you have lost its password, use reset_password above instead.


Troubleshooting

"Cannot access feature" / button is greyed out

A control disabled with a tooltip ("you need X permission") means your effective permission set is missing it. Walk the chain:

  1. Open Settings -> Users, find your row, and verify which groups you're in.
  2. Open Settings -> Users -> Groups, click each of your groups, confirm the missing permission is ticked.
  3. If you should have access but don't see it, ask an admin to add the permission to one of your groups (or move you to a group that already has it).
  4. For *_own vs *_all mismatch: check whether the resource is ownerless -- if so, only *_all works.

Session expired mid-action

Access tokens are 1 hour. Normally the refresh cookie keeps you signed in transparently; if refresh also fails (cookie expired, server restarted with new secret, password changed elsewhere), you're hard-redirected to /login. Sign in and resume -- in-flight forms are not preserved.

"setup_required" 503 after upgrade

The setup-gate cache thinks no admin exists. Restart the container -- the gate is cleared on next boot if any admin row is present in the DB. If it persists after restart, the admin user was likely deleted; run python -m backend.app.cli reset_admin and re-create.

Forgot password (no SMTP)

With no mail server configured there is no self-service recovery, so the login page shows "ask your administrator" instead of a link. An admin sets a new one from Settings -> Users -> Edit, or --- if the admin is the one locked out --- from the server console.

"Forgot your password?" is missing on the login page

It is shown only when a reset email can actually be sent: SMTP configured and local login enabled. Check Settings -> Email first, and use Test email --- the link appears as soon as the server can send. It does not depend on the Advanced Auth toggle.

Links last 1 hour and work once. Requesting a new one also retires the previous link, so an older email in the inbox will report exactly this. Ask for a fresh one. If every link fails immediately, check that External URL under Settings -> Email is the address users actually reach --- the link is built from it.

LDAP users can't log in but local admin can

Almost always a directory connectivity issue. Open Settings -> Authentication -> LDAP -> Test connection and read the raw error. Common causes: VPN dropped, AD service-account password rotated, LDAPS cert expired, firewall closed 636/389.


Tips

Enrol TOTP for every admin

Admin accounts hold the keys to the farm. TOTP + offline backup codes is the minimum bar for any account that can change settings or delete archives.

Encrypt MFA secrets

Set MFA_ENCRYPTION_KEY before enrolling users. Plaintext secrets work, but encrypted-at-rest is one less thing on the list when you do your next backup audit.

Use OIDC for teams

If you already run Authentik / Keycloak / Pocket-ID for the rest of your homelab, wire BamDude into it -- you get group sync, MFA, and offboarding for free instead of maintaining a parallel password store.

Print the backup codes

Backup codes are shown once. Print them, drop them in your password manager's secure notes, or both -- but don't trust yourself to remember to write them down later.