Pocket ID: Easy Passkey Authentication

24 points by abnercoimbre a day ago on lobsters | 8 comments

Waaay back in June, I posted about how I was using tsidp for effortless SSO in my tailnet. I closed that post with a promise that I would "soon" share details about my other OIDC setup. Well buckle up -- it's time for me to make good on that promise.

tsidp↗ is really great for automatically logging into stuff just by virtue of being connected to your tailnet. But what about stuff that you might want to access from other networks1? Or maybe you want/need more control over the login experience, or to be able to allow certain other people to access your stuff. tsidp doesn't really work as well for those sorts of use cases2.

Enter Pocket ID↗:

A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.

Pocket ID is very simple to set up and use, and I think it's particularly cool that it only supports passkey authentication. You set up a single passkey for Pocket ID and that will grant you easy access to any app for which you can configure OpenID Connect authentication. It's worked really well for providing me with out-of-tailnet access to a ton of stuff that I selfhost, including Forgejo, Linkding, OpenGist, and Coder3.

This post will go over how I deployed and configured Pocket ID as well as how to set it up as an authentication source for a few of those services.

Deployment

I wanted my Pocket ID deployment to be accessible from anywhere so I deployed it as a Docker Compose stack on a Hetzner VPS. My server runs Ubuntu 24.04 but any Linux flavor should work as long as it's got Docker installed↗. I front basically all my Docker deployments with a Caddy reverse proxy; I'm not going to detail that installation process↗ here either.

Pocket ID has a really nice installation guide↗ to help get things started, in case you don't want to just take my word for it.

DNS

This project begins, as many often do, with creating a DNS record for the thing I'm about to deploy. Naming things is hard, so I like to get it out of the way up front.

So I used DNSControl to create an A record for id.example.com pointed to the public IP of my VPS.

Docker Compose

Then I needed to create the compose file to define the deployment. This is just a lightly-adjusted version of the default↗.

services:

pocket-id:

image: ghcr.io/pocket-id/pocket-id:v2

restart: unless-stopped

ports:

- 1411:1411

volumes:

- /opt/docker/pocket-id/data:/app/data

environment:

APP_URL: "${APP_URL}"

ENCRYPTION_KEY: "${ENCRYPTION_KEY}"

TRUST_PROXY: "true"

MAXMIND_LICENSE_KEY: "${MAXMIND_LICENSE_KEY}"

PUID: 1000

PGID: 1000

# Optional healthcheck

healthcheck:

test: "curl -f http://localhost:1411/healthz"

interval: 1m30s

timeout: 5s

retries: 2

start_period: 10s

Some key details:

  • Pocket ID will be served locally on port 1411.
  • Its data (including the default sqlite database) will persist in /opt/docker/pocket-id/data.
  • It will run with UID/GID 1000. Make sure to set that to whatever user actually owns the data directory so it can write to it and stuff.

And some environment variables to note:

VariableValue/Description
APP_URLhttps://id.example.com, the public address where this thing will live
ENCRYPTION_KEYwill be used to encrypt data, including private keys; generate with openssl rand -base64 32
TRUST_PROXYtrue, since this will be served behind a reverse proxy which we should trust
MAXMIND_LICENSE_KEYused for geolocating login attempts for audit purposes, get a free key here↗

Caddy reverse proxy

Caddy makes things almost too easy. This is all that needs to go in to /etc/caddy/Caddyfile to securely serve our little Pocket ID service with automatic TLS magic:

id.example.com {

reverse_proxy http://localhost:1411

}

This will direct requests for https://id.example.com over to the Pocket ID container that listening on port 1411.

Initial start

With the pieces in place, it's time to bring them up.

# start the container stack

sudo docker compose up -d

# reload the caddyfile

sudo caddy reload -c /etc/caddy/Caddyfile

Now I can just point my browser to https://id.example.com and see the lovely login page: The Pocket ID default login page instructing the user to authenticate with a passkey to login. There's a lovely photo of a snow-covered mountain in the background.

Settling in

Well Pocket ID is up and running, but it's not doing me a whole lot of good at this point. I can't even log in!

Let's get that sorted.

Initial login

Since this is a brand new deployment, there aren't any accounts yet. I'll need to create one so that I can get in. So I'll repoint my browser to https://id.example.com/setup to perform that initial setup.

A sign-up page asking for first name, last name, username, and email address.

After filling in my details, and clicking Sign Up, I get prompted to set up my first passkey:

Pocket ID prompting me to set up my first passkey.

I click Add Passkey and go through the steps to add a new passkey in my password manager.

And then I'm in! Successfully logged in to Pocket ID. This page lists some of my account information, and there's a banner advising me to add more than one passkey to ensure I don't lose access to the account.

See that banner recommending that I add another passkey so I don't lose access? That sounds like a pretty good idea. Passkeys are really cool, but they can be a bit finicky at times. It's important to ensure I have multiple ways to log in if needed.

So I scroll down a bit to the Passkeys section and click the Add Passkey button above my existing passkey. The Passkeys section shows my existing Bitwarden Passkey, and there's a button to add another.

This time I dismiss my password manager's passkey handler and use my Yubikey instead. Now I'll be able to log in to Pocket ID using either my password manager or my physical token.

No fallbacks

Most sites which implement support for passkey authentication allow you to fall back to some less secure method if your passkey isn't available, like an SMS or email verification code. That kind of defeats (or rather bypasses entirely) the extra security provided by passkeys.

Pocket ID doesn't give you any other options. If you can't use one of your passkeys to get in, you can't get in. Period.

(Okay, technically the Pocket ID administrator can optionally generate and send email backup codes if a non-administrator account can't otherwise log in. And there's an option to allow users to request their own backup codes but please don't enable that as (again) it really decreases security.)

Enroll multiple passkeys.

Application configuration

Pocket ID offers a number of knobs ready to be tweaked under Administration -> Application configuration. You can change the color scheme and application branding, control whether users should be able to edit their own account information, restrict user sign-ups, and enable syncing account details from an LDAP server.

One thing that I thought was pretty important to set up is the email integration.

A screen listing a number of options related to email. There are fields for specifying how to authenticate with the mail server as well as toggles for requiring users to have email addresses, requiring verification of the email addresses, and generating email alerts for login activity.

I'm using forwardemail.net↗ as my mail server which makes it really easy to create/manage email accounts across all my domains, but the setup should largely be the same with any other email provider that supports external SMTP connections.

Beyond just configuring the account that will be used for sending Pocket ID-related email, I also enabled these options:

  • Require Email Address so that each account must have an email associated with it (that's often required by the downstream OIDC client anyway)
  • Email Login Notification so that the user will get an email notification confirming their sign-in activity
  • Email Verification to have Pocket ID send an email to new users to verify their email address
  • Email Login Code from Admin so that the admin can trigger sending a login code to a user if they lose access to their account

I did not enable the Email Login Code Requested by User because I don't want to expose the option to request a login code in case an email account has been compromised.

Adding clients

So now I can log in to my Pocket ID instance with my passkey, and I can even get an email notification to let me know about it. That's cool, but not particularly useful one its own.

Let's add some OIDC client configurations so that I can log into other stuff with my passkey.

Regardless of what application I'm trying to connect, the setup in Pocket ID always starts the same: go to Administration -> OIDC Clients and click Add OIDC Client.

Creating an OIDC client. There are text fields for Name and Client Launch URL, optional additional text fields for Callback URLs, and toggles for Public Client, PKCE, and Requires Re-Authentication.

  • Name is the name to set for the app in Pocket ID.
  • Client Launch URL can be set to the main URL of the connected app.
  • Callback URLs tell Pocket ID what URLs to expect an authentication request to come from. The client documentation will probably tell you what to put here.
  • The Public Client option is useful if you can't trust the client to hold a secret on its own (such as a web or mobile app deployed on equipment you don't control). I've not needed to enable that for any of my stuff (yet, at least).
  • PKCE adds a bit more security and protection to the connection, but may not be supported by all clients. I generally enable it for all new apps and disable it if the client doesn't work.

I'll cover what settings I used for some clients in just a moment; I just wanted to show this screen once up front rather than needing to repeat it each time.

Once a client has been created, there's another thing that will need to be handled before it can actually be used. Further down the page, there will be a section labeled Allowed User Groups.

By default, no users are granted access to a newly configured app.

By default, no users are granted access to a newly configured application. You can create groups of users and control which groups have access to which applications (really handy if/when you start extending access to people outside your home), but for now let's keep it simple and just click the Unrestrict button so that anyone with access to our Pocket ID instance (which, realistically, is just me) can access the app. Remember, this will need to be repeated for each new app before you can log in.

Well Known

Some OIDC clients are really smart and can figure out how to talk a provider just based on the base URL (https://id.example.com), while others might need to be pointed to the well-known endpoint https://id.example.com/.well-known/openid-configuration.

Still others may give you a bunch of fields to fill out; you can generally find the answers you need for that by reviewing the JSON document returned by the /.well-known/openid-configuration endpoint. Give it a look if you get stuck.

Pocket ID has a bunch of detailed guides for configuring a number of OIDC clients↗, and basically every application which supports authenticating against an OIDC provider will include some amount of documentation for how to configure it as well. The point is that this is a standard auth method and there should be documentation to help figure out how to configure it for your use case.

Here are a few of the client apps I've configured to auth through my Pocket ID deployment.

Forgejo

I still have the client creation page open in Pocket ID, but I haven't done anything with it yet. I open a new browser tab, log into my Forgejo instance, and navigate to Site administration -> Identity & access -> Authentication sources and click Add authentication source.

I click the Authentication type dropdown and set it to OAuth2. I set the name to Pocket, and then set the OAuth2 Provider option to OpenID Connect.

I won't be able to fill in the Client ID or secret until after I finish creating the client in Pocket ID, and I'll need some more info before I can do that.

Adding an authentication source in Forgejo. Authentication type is OAuth2, name is Pocket, and provider is OpenID Connect. Client ID and secret are currently blank.

Scrolling down near the bottom of the Forgejo page, there's a helpful Tips section which includes the following pointers:

OAuth2 authentication:

When registering a new OAuth2 authentication, the callback/redirect URL should be: https://git.example.com/user/oauth2/Pocket/callback

OpenID Connect

Use the OpenID Connect Discovery URL (/.well-known/openid-configuration) to specify the endpoints

So I can now complete the setup on the Pocket ID side of things:

FieldValue
NameForgejo
Client Launch URLhttps://git.example.com (the address of my Forgejo instance)
Callback URL(s)https://git.example.com/user/oauth2/Pocket/callback
PKCEenabled

After clicking Save I am presented with the new client ID and client secret, which I can paste back into Forgejo.

And in Forgejo I set the OpenID Connect Auto Discovery URL to https://id.example.com/.well-known/openid-configuration.

I also enable Skip local 2FA, and set the Additional scopes to openid email profile so that Forgejo will be able to access user email address and profile information.

With everything filled out I can click the Add authentication source button to save and apply the setup.

I can then log out of Forgejo and try to log back in using the new Sign in with Pocket option.

Pocket ID prompting to sign in to Forgejo.

Assuming that my username and email address match the local account I have in Forgejo, I get prompted to link that existing account by logging into it with my existing password.

Next time, my Pocket ID passkey will let me in right away.

OpenGist

Based on those docs, I'll use http://opengist.example.com/oauth/openid-connect/callback as the callback URL when I create the app config in Pocket ID.

Unlike Forgejo, OpenGist's OIDC configuration is done by setting environment variables in the compose file. Here's an example of what that might look like:

services:

opengist:

image: ghcr.io/thomiceli/opengist:1

ports:

- 6157:6157

volumes:

- ./data:/opengist

environment:

OG_OIDC_PROVIDER_NAME: Pocket-ID

OG_OIDC_CLIENT_KEY: ${OG_OIDC_CLIENT_KEY:-}

OG_OIDC_SECRET: ${OG_OIDC_SECRET:-}

OG_OIDC_DISCOVERY_URL: "https://id.example.com/.well-known/openid-configuration"

OpenGist login page featuring a button to Connect with Pocker ID account

Linkding

Same pattern: I look at the docs, see that the callback URL should be https://linkding.example.com/oidc/callback/, and create the new app in Pocket ID.

Linkding is also configured via environment variables:

services:

linkding:

image: sissbruecker/linkding:latest

ports:

- 9090:9090

volumes:

- ./data:/etc/linkding/data

environment:

LD_ENABLE_OIDC: "True"

LD_HOST_PORT: 9090

LD_SUPERUSER_NAME: ${LD_SUPERUSER_NAME:-admin}

LD_SUPERUSER_PASSWORD: ${LD_SUPERUSER_PASSWORD:-}

OIDC_RP_CLIENT_ID: ${OIDC_CLIENT_ID}

OIDC_RP_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}

OIDC_OP_AUTHORIZATION_ENDPOINT: https://id.example.com/authorize

OIDC_OP_TOKEN_ENDPOINT: https://id.example.com/api/oidc/token

OIDC_OP_USER_ENDPOINT: https://id.example.com/api/oidc/userinfo

OIDC_OP_JWKS_ENDPOINT: https://id.example.com/.well-known/jwks.json

OIDC_USERNAME_CLAIM: preferred_username

Signing in to Linkding with Pocket ID

Coder

The Coders docs tell me to set the callback URL to https://coder.example.com/api/v2/users/oidc/callback, so I do what when creating the application in Pocket ID.

Once again, I'll update the OIDC configuration in the Coder compose file. Coder is apparently smart enough to figure out all the endpoints with just Pocket ID's base URL.

services:

coder:

image: ghcr.io/coder/coder:latest

ports:

- "7080:7080"

environment:

CODER_PG_CONNECTION_URL: "postgresql://${POSTGRES_USER:-username}:${POSTGRES_PASSWORD:-password}@database/${POSTGRES_DB:-coder}?sslmode=disable"

CODER_HTTP_ADDRESS: "0.0.0.0:7080"

CODER_ACCESS_URL: "${CODER_ACCESS_URL}"

CODER_WILDCARD_ACCESS_URL: "${CODER_WILDCARD_ACCESS_URL}"

CODER_EXTERNAL_AUTH_0_ID: "${CODER_EXTERNAL_AUTH_0_ID}"

CODER_EXTERNAL_AUTH_0_CLIENT_ID: "${CODER_EXTERNAL_AUTH_0_CLIENT_ID}"

CODER_EXTERNAL_AUTH_0_CLIENT_SECRET: "${CODER_EXTERNAL_AUTH_0_CLIENT_SECRET}"

CODER_EXTERNAL_AUTH_0_AUTH_URL: "${CODER_EXTERNAL_AUTH_0_AUTH_URL}"

CODER_EXTERNAL_AUTH_0_TOKEN_URL: "${CODER_EXTERNAL_AUTH_0_TOKEN_URL}"

CODER_EXTERNAL_AUTH_0_VALIDATE_URL: "${CODER_EXTERNAL_AUTH_0_VALIDATE_URL}"

CODER_EXTERNAL_AUTH_0_REGEX: "${CODER_EXTERNAL_AUTH_0_REGEX}"

CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE: false

CODER_OIDC_ISSUER_URL: "https://id.example.com"

CODER_OIDC_EMAIL_DOMAIN: "example.com"

CODER_OIDC_CLIENT_ID: "${CODER_OIDC_CLIENT_ID}"

CODER_OIDC_CLIENT_SECRET: "${CODER_OIDC_CLIENT_SECRET}"

CODER_DISABLE_PASSWORD_AUTH: true

volumes:

- /var/run/docker.sock:/var/run/docker.sock

# Run "docker volume rm coder_coder_home" to reset the dev tunnel url (https://abc.xyz.try.coder.app).

# This volume is not required in a production environment - you may safely remove it.

# Coder can recreate all the files it needs on restart.

- coder_home:/home/coder

group_add:

- 999

depends_on:

database:

condition: service_healthy

database: { ... }

image: "postgres:17"

environment:

POSTGRES_USER: ${POSTGRES_USER:-username} # The PostgreSQL user (useful to connect to the database)

POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} # The PostgreSQL password (useful to connect to the database)

POSTGRES_DB: ${POSTGRES_DB:-coder} # The PostgreSQL default database (automatically created at first launch)

volumes:

- coder_data:/var/lib/postgresql/data # Use "docker volume rm coder_coder_data" to reset Coder

healthcheck:

test:

[

"CMD-SHELL",

"pg_isready -U ${POSTGRES_USER:-username} -d ${POSTGRES_DB:-coder}",

]

interval: 5s

timeout: 5s

retries: 5

volumes:

coder_data:

coder_home:

Signing into Coder with Pocket ID

Outro

This login flow isn't quite as effortless as what I get with my purely-internal systems via tsidp4, but it's still pretty smooth. It's nice to be able to use modern passkey-based authentication for my self-hosted apps, and I appreciate not having to rely on some Big Tech passkey implementation to make it work.

And I really like enforcing passwordless logins for the apps I expose to the internet.