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.
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.
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.
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:
1411./opt/docker/pocket-id/data.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:
| Variable | Value/Description |
|---|---|
APP_URL | https://id.example.com, the public address where this thing will live |
ENCRYPTION_KEY | will be used to encrypt data, including private keys; generate with openssl rand -base64 32 |
TRUST_PROXY | true, since this will be served behind a reverse proxy which we should trust |
MAXMIND_LICENSE_KEY | used for geolocating login attempts for audit purposes, get a free key here↗ |
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.
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:

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

After filling in my details, and clicking Sign Up, I get prompted 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!

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.

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

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

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

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:
| Field | Value |
|---|---|
| Name | Forgejo |
| Client Launch URL | https://git.example.com (the address of my Forgejo instance) |
| Callback URL(s) | https://git.example.com/user/oauth2/Pocket/callback |
| PKCE | enabled |
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.

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

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

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:

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.