Pocket ID
Dashy supports using Pocket ID as its OIDC provider.
Pocket ID is a small open source identity provider built around passkeys. It's a single Go binary backed by SQLite (or Postgres if you want), no password storage, no email server needed for basic use. Popular in homelab setups where you want SSO across a handful of services without running Authentik or Keycloak.
Contentsβ
- 1. Deploy Pocket ID
- 2. Configure Pocket ID
- 3. Enabling Pocket ID in Dashy
- 4. Groups and Visibility
- Troubleshooting
- How it Works
1. Deploy Pocket IDβ
A minimal compose:
Example docker-compose.yml
name: dashy-pocketid
services:
pocket-id:
image: ghcr.io/pocket-id/pocket-id:v2.7.0
restart: unless-stopped
ports:
- "1411:1411"
environment:
APP_URL: http://localhost:1411
TRUST_PROXY: "false"
DB_PROVIDER: sqlite
DB_CONNECTION_STRING: /data/pocket-id.db
ENCRYPTION_KEY: "local-dev-key-32-bytes-padding!!"
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1411/healthz"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
A few things worth understanding before you start:
APP_URLis the URL the browser uses to reach Pocket ID. It must match exactly, since Pocket ID puts that value in the OIDC issuer, in cookie domains, and in the WebAuthn relying partyENCRYPTION_KEYis required and must be at least 16 bytes. Generate a real one withopenssl rand -hex 32for production; the example above is for local testing only- Pocket ID is passkey-only by default. Passkeys (WebAuthn) require a secure context, which means either HTTPS, or a literal
localhosthost.pocketid.lvh.meand similar lookalikes don't qualify, only the exact stringlocalhost. For production use HTTPS with a real domain; for local testing stick tohttp://localhost
For production:
- Put Pocket ID behind a reverse proxy with HTTPS on a real domain
- Set
APP_URL: https://auth.example.com - Set
TRUST_PROXY: "true"so it honours forwarded headers - Use a 32-byte random
ENCRYPTION_KEY - Consider swapping
DB_PROVIDERtopostgresfor redundancy
Bring Pocket ID up:
docker compose up -d pocket-id
Wait for it to be healthy (curl -sf http://localhost:1411/healthz), then continue.
2. Configure Pocket IDβ
First-time admin setupβ
There's no env-var bootstrap. You create the first admin through the UI, or via SQLite if you want to script it.
Through the UI:
- Open
http://localhost:1411in a browser - The setup wizard prompts for a first admin (username, email, name)
- After creating the user, register a passkey when prompted
If you can't easily register a passkey (no compatible device, or running headless), insert the admin user via SQLite and then use the CLI to get a one-time login URL:
docker compose exec pocket-id sh -c '
apk add --no-cache sqlite >/dev/null 2>&1
sqlite3 /data/pocket-id.db "
INSERT INTO users (id, created_at, updated_at, username, email, first_name, last_name, display_name, is_admin, email_verified)
VALUES (\"admin-uuid-001\", datetime(\"now\"), datetime(\"now\"), \"pocketid-admin\", \"[email protected]\", \"Pocket ID\", \"Admin\", \"Admin\", 1, 1);
"
'
docker compose exec pocket-id /app/pocket-id one-time-access-token pocketid-admin
The CLI prints a URL like http://localhost:1411/lc/<token>. Open it in a browser to log in. The token is single-use and expires in 1 hour. Once signed in, register a passkey under Settings > Passkeys so you don't need to keep generating OTA tokens.
Create the OIDC clientβ
- Open Admin > OIDC Clients
- Click Add OIDC Client
- Set Name to
Dashy - Set Callback URLs to both:
http://localhost:4000http://localhost:4000/
- Set Logout Callback URLs to
http://localhost:4000 - Turn Public Client on
- Turn PKCE on
- Click Save
Pocket ID generates a client ID (a UUID, e.g. 268a3701-e9ec-41f6-bad5-87c78ce87c94). Copy it; you'll need it for Dashy's config.
Create the admin groupβ
- Open Admin > User Groups
- Click Add Group
- Set Name to
Dashy admins(or anything you like) - Set Friendly name to
admins(this is what Pocket ID emits in the OIDCgroupsclaim, and what Dashy matches against) - Click Save
Create test usersβ
- Open Admin > Users
- Click Add User
- Fill in Username, Email, First Name, Last Name
- Save, then optionally open the user and assign them to the
adminsgroup via the Groups tab - Either share the OTA URL (via the CLI command above) with the user, or have them set up a passkey on first sign-in
Summaryβ
Pocket ID should now have an OIDC client, an admin group, and at least one admin user in that group.
3. Enabling Pocket ID in Dashyβ
In /user-data/conf.yml:
appConfig:
...
disableConfigurationForNonAdmin: true
auth:
enableOidc: true
oidc:
clientId: 268a3701-e9ec-41f6-bad5-87c78ce87c94
endpoint: http://localhost:1411
adminGroup: admins
scope: openid profile email groups
Where:
disableConfigurationForNonAdmin- Prevent read/write config access to non-admin usersauth.enableOidc- Set the auth mode to OIDCclientId- The client ID from Pocket ID. It's a UUID, so no YAML quoting neededendpoint- Pocket ID'sAPP_URL. Must match exactly. Dashy appends/.well-known/openid-configurationitselfadminGroup- The group's friendly name (not the display name) that grants admin in Dashyscope-groupsis needed for theadminGroupcheck. Pocket ID emits the claim by default when the scope is requested
Restart Dashy after editing.
If Pocket ID runs behind a reverse proxy, make sure endpoint and APP_URL agree, and that Dashy can reach endpoint from its container. With both services in Docker, you can either put them on the same network and use the service name, or use the host's exposed port.
Everything should now be fully configured and working π When you load Dashy, you'll be redirected to Pocket ID's sign-in page. Authenticate with your passkey (or paste a one-time code), and you'll land back on Dashy with full access. All of Dashy's client, server and asset endpoints will be locked behind authentication.
4. Groups and Visibilityβ
Once the groups claim is in the id_token, you can use it to hide or show pages, sections and items. The property name is hideForKeycloakUsers / showForKeycloakUsers (the name is historical; it works for any OIDC provider, including Pocket ID).
To make an Admin section visible only to members of admins:
displayData:
showForKeycloakUsers:
groups:
- admins
Both showForKeycloakUsers and hideForKeycloakUsers accept lists of groups and roles. If a user matches an entry they're allowed or excluded as defined.
sections:
- name: Internal Tools
displayData:
showForKeycloakUsers:
groups: ['admins']
hideForKeycloakUsers:
groups: ['guests']
items:
- title: Hidden from interns
displayData:
hideForKeycloakUsers:
groups: ['interns']
Troubleshooting common Pocket ID issuesβ
Pocket ID won't start, "ENCRYPTION_KEY must be at least 16 bytes long"β
Problem: Container restarts on boot, logs show the encryption key error.
Solution: Set ENCRYPTION_KEY to a string of 16 characters or more. For production generate a real random one (openssl rand -hex 32 gives you 64 hex chars).
"WebAuthn is not supported in this browser"β
Problem: Browser console shows the WebAuthn error and passkey registration or login fails.
Solution: WebAuthn needs a secure context. Browsers grant this for HTTPS, or for the literal hostname localhost. pocketid.lvh.me, 127.0.0.1, or anything else over HTTP won't qualify. For local testing, set APP_URL: http://localhost:1411 and access Pocket ID via http://localhost:1411. For production, terminate HTTPS in front of Pocket ID.
"Cookie 'session' has been rejected because a non-HTTPS cookie can't be set as 'secure'"β
Problem: Browser refuses to store Pocket ID's session cookie, so login appears to succeed but the next request comes back as not signed in.
Solution: Same root cause as the WebAuthn one. Pocket ID always sets cookies with the Secure flag, which browsers will only accept on HTTPS or on localhost. Switch to http://localhost or use HTTPS.
One-time access token returns "Token is invalid or expired"β
Problem: The CLI gave you a URL, but visiting it returns the invalid/expired error.
Solution: OTA tokens are single-use and expire after 1 hour. If you've already used the URL or it's stale, generate a fresh one: docker compose exec pocket-id /app/pocket-id one-time-access-token <username>. The earlier "Cookie rejected as Secure" issue can also cause this to look like a token problem because the session cookie from the OTA exchange never persisted.
Logged in but config saves return 403β
Problem: User authenticates fine, but saving the dashboard returns 403.
Solution: The groups claim isn't matching. Two things to check. First, that adminGroup in conf.yml equals the group's friendly name, not the display name. Second, that the user is actually in the group via Admin > Users > [user] > Groups. You can decode the id_token at jwt.io (take it from localStorage, key ID_TOKEN) to see what's in the claim.
Redirect URI mismatchβ
Problem: Pocket ID returns an invalid_request for the redirect URI on the authorize step.
Solution: Pocket ID matches callback URLs exactly. Register both the bare URL and the trailing-slash variant (http://localhost:4000 and http://localhost:4000/) on the OIDC client, and make sure the scheme matches what Dashy is actually served on.
Dashy server can't reach Pocket IDβ
Problem: Auth'd API calls return 401 and Dashy logs show fetch errors for .well-known/openid-configuration.
Solution: endpoint must be reachable from inside the Dashy container. If both run in Docker, share a network and use service names, or use network_mode: service:pocket-id (then localhost inside Dashy resolves to Pocket ID). Test with docker exec <dashy-container> wget -qO- "$ENDPOINT/.well-known/openid-configuration".
Issuer mismatch on token verificationβ
Problem: Dashy server logs show unexpected "iss" claim value. The token's iss claim doesn't match Dashy's endpoint.
Solution: Pocket ID puts APP_URL straight into the issuer. Make sure APP_URL, endpoint in Dashy's conf.yml, and the URL the browser uses are all the same string (same scheme, host, and port).
First admin can't sign in, no setup page appearsβ
Problem: Visiting Pocket ID just shows a login form, no setup wizard.
Solution: A previous Pocket ID instance may have created users in the database. Either log in as one of those users, or wipe data/ and start fresh: docker compose down && rm -rf data && docker compose up -d. (The data dir is owned by the container user, so you may need sudo or use a throwaway container to clean up.)
Token expired / clock skewβ
Problem: 401s with "exp" claim timestamp check failed, even just after login.
Solution: Dashy allows 30 seconds of drift. Sync clocks (NTP) on both hosts. Container clocks follow their host, so it's almost always the host that's drifted.
Config change to auth.oidc not picked upβ
Problem: Updated clientId, endpoint, adminGroup or scope in conf.yml, but Dashy still uses the old values.
Solution: The server reads the auth config only at boot. Restart the Dashy container after any change to fields under auth.oidc.
How it Worksβ
If you're a developer or contributor looking to understand or make changes to Dashy's OIDC implementation, the following outlines how it's wired together.
The same OIDC pipeline backs Pocket ID, Authelia, Authentik, Keycloak, Zitadel, and any other generic OIDC provider. The only Pocket ID specific quirk is that it's passkey-first; everything on the Dashy side is shared.
Client sideβ
Boot starts in src/main.js. After the initial /conf.yml fetch parses the auth block, isOidcEnabled() decides whether to lazily import oidc-client-ts and call initOidcAuth().
src/utils/auth/OidcAuth.js wraps oidc-client-ts. On load it inspects the URL: if it sees a ?code= callback it runs userManager.signinCallback() to exchange the code (and PKCE verifier) for tokens, persists the user info, and hard-redirects to /. Otherwise it calls userManager.getUser(); if there's no usable session it falls through to userManager.signinRedirect() to send the browser to Pocket ID. A short-lived sessionStorage guard prevents the redirect loop that would otherwise occur if the IdP returns without a usable user.
persistUserInfo() writes the raw id_token, the user's groups and roles, a derived isAdmin flag, and a username (falling back through preferred_username, email, and sub) to localStorage. The keys (ID_TOKEN, KEYCLOAK_INFO, USERNAME, ISADMIN) live in src/utils/config/defaults.js; the KEYCLOAK_INFO name is historical and reused for all OIDC providers, including Pocket ID.
src/utils/auth/getApiAuthHeader.js builds the Authorization header for every internal API call. It does a client-side exp check and returns null for missing or expired tokens, so the next request triggers a fresh login rather than a 401.
src/utils/IsVisibleToUser.js reads KEYCLOAK_INFO when evaluating showForKeycloakUsers and hideForKeycloakUsers rules.
Server sideβ
services/auth-oidc.js contains the entire server-side auth surface, in five small pieces:
loadOidcSettings()readsauth.oidc(orauth.keycloak) at boot and returns a normalised{ issuer, clientId, adminGroup, adminRole }. For generic OIDC providers theissueris whatever you set asendpointinconf.yml, verbatimcreateOidcMiddleware()returns a Connect middleware. Permissive on no-token requests so the SPA can bootstrap; otherwise it verifies the Bearer token against the issuer's JWKS usingjose. Checks cover signature, issuer (against the canonical value from the discovery doc), audience (must equalclientId), and expiry, with a 30-second clock-skew tolerance. Setsreq.auth = { user, isAdmin, claims }on success,401on failuregetIssuerContext()lazily fetches.well-known/openid-configurationon first use and wrapsjwks_uriincreateRemoteJWKSet, which handles JWKS caching and on-demand key rotation. The result is memoised per-issuer for the life of the processderiveIsAdmin()checks the token'sgroupsclaim againstadminGroup, and therealm_access.roles/resource_access.<clientId>.rolesarrays againstadminRole. Pocket ID emitsgroupsnatively, so the group path is what's used in practicemaybeBootstrapConfig()is the stripped-response helper. When auth is configured, guest access is off, and an unauthenticated request hits the root/conf.yml, it returns a minimal copy with onlyappConfig.auth,appConfig.enableServiceWorker, and apageInfo.titleofLogin | <your title>. Sections, items, hostnames and any other secrets never leave the server
services/app.js wires it all together. The middleware mounts as protectConfig in front of every YAML route and config-mutating route. The /*.yml handler sets Cache-Control: private, no-store and Vary: Authorization whenever auth is configured (so intermediate caches can never mix auth states), then calls maybeBootstrapConfig; a stripped result is sent as-is, otherwise res.sendFile serves the full file. POST /config-manager/save is additionally guarded by requireAdmin, which returns 401 if req.auth is unset and 403 if req.auth.isAdmin is false.