Microservice to bring 2FA to self hosted PDSes

PDS Admin Portal#

Overview#

Bluesky's PDS admin API relies on PDS_ADMIN_PASSWORD — a single shared secret that grants unrestricted access to every administrative endpoint. This is workable for solo operators but becomes a liability when multiple team members need admin access. There is no way to limit what any individual can do, no audit trail of who performed an action, and credential rotation affects everyone simultaneously.

The pds-gatekeeper admin portal solves this by introducing role-based access control (RBAC). Team members authenticate with ATProto OAuth using their own identity, and gatekeeper enforces per-user permissions based on a YAML configuration file. Authorized requests are proxied to the PDS using the admin password on behalf of the authenticated user — the password itself is never exposed to browsers or end users.

Prerequisites#

  • A PDS instance running behind pds-gatekeeper
  • HTTPS with a valid TLS certificate (required for ATProto OAuth flows)
  • SMTP configured on the PDS for email functionality (used by the PDS itself, not strictly by the admin portal)

Quick Start#

1. Create an RBAC configuration file#

Copy the example configuration as a starting point:

cp examples/admin_rbac.yaml /path/to/your/admin_rbac.yaml

2. Find your team members' DIDs#

Use goat to resolve a handle to its DID:

goat resolve alice.example.com

The DID is the id field in the output (e.g. did:plc:abcdef1234567890).

3. Set environment variables#

# Required
GATEKEEPER_ADMIN_RBAC_CONFIG=/path/to/your/admin_rbac.yaml
PDS_ADMIN_PASSWORD=your-pds-admin-password

# Optional
GATEKEEPER_ADMIN_COOKIE_SECRET=<64-character-hex-string>
GATEKEEPER_ADMIN_SESSION_TTL_HOURS=24

4. Restart pds-gatekeeper#

# If running with systemd:
sudo systemctl restart pds-gatekeeper

# If running with Docker:
docker restart pds-gatekeeper

5. Navigate to the admin portal#

Open your browser and go to:

https://your-pds.example.com/admin/login

RBAC Configuration#

The RBAC configuration is a YAML file with two top-level sections: roles and members.

  • Roles define named sets of endpoint patterns that grant access to specific admin operations.
  • Members map an ATProto DID to one or more roles.

A member's effective permissions are the union of all endpoints from all of their assigned roles.

Endpoint patterns support wildcard matching: com.atproto.admin.* matches all endpoints under the com.atproto.admin namespace.

Example:

roles:
  pds-admin:
    endpoints:
      - "com.atproto.admin.*"
      - "com.atproto.server.createInviteCode"
      - "com.atproto.server.createAccount"

  moderator:
    endpoints:
      - "com.atproto.admin.getAccountInfo"
      - "com.atproto.admin.getAccountInfos"
      - "com.atproto.admin.getSubjectStatus"
      - "com.atproto.admin.updateSubjectStatus"
      - "com.atproto.admin.sendEmail"
      - "com.atproto.admin.getInviteCodes"

  invite-manager:
    endpoints:
      - "com.atproto.server.createInviteCode"
      - "com.atproto.admin.getInviteCodes"
      - "com.atproto.admin.disableInviteCodes"
      - "com.atproto.admin.enableAccountInvites"
      - "com.atproto.admin.disableAccountInvites"

members:
  - did: "did:plc:abcdef1234567890"
    roles:
      - pds-admin

  - did: "did:plc:bbbbbbbbbbbbbbbb"
    roles:
      - moderator
      - invite-manager

Available Roles (Reference)#

Suggested role templates#

You can make your own roles and teams

Role Description Endpoints
pds-admin Full administrative access com.atproto.admin.*, createInviteCode, createAccount
moderator View accounts, manage takedowns, send email, view invite codes getAccountInfo, getAccountInfos, getSubjectStatus, updateSubjectStatus, sendEmail, getInviteCodes
invite-manager Manage invite codes and per-account invite permissions createInviteCode, getInviteCodes, disableInviteCodes, enableAccountInvites, disableAccountInvites

All admin XRPC endpoints#

Endpoint Description
com.atproto.admin.getAccountInfo View single account details
com.atproto.admin.getAccountInfos View multiple accounts
com.atproto.admin.getSubjectStatus Get takedown status
com.atproto.admin.updateSubjectStatus Apply or remove takedowns
com.atproto.admin.deleteAccount Permanently delete an account
com.atproto.admin.updateAccountPassword Reset account password
com.atproto.admin.enableAccountInvites Enable invites for an account
com.atproto.admin.disableAccountInvites Disable invites for an account
com.atproto.admin.getInviteCodes List invite codes
com.atproto.admin.disableInviteCodes Disable specific invite codes
com.atproto.admin.sendEmail Send email to an account
com.atproto.server.createInviteCode Create a new invite code
com.atproto.server.createAccount Create a new account

How It Works#

1. OAuth Login#

The user navigates to /admin/login and enters their ATProto handle. Gatekeeper initiates an OAuth authorization flow, redirecting the user to their identity's authorization server. The user authenticates there and is redirected back to gatekeeper with an authorization code.

2. Session Creation#

Gatekeeper exchanges the authorization code for tokens, extracts the user's DID from the OAuth session, and checks it against the RBAC configuration. If the DID is found in the members list, a signed session cookie is created and set in the browser.

3. Request Flow#

When the user performs an admin action, gatekeeper:

  1. Validates the session cookie signature and expiration
  2. Looks up the user's DID in the RBAC configuration
  3. Checks whether the user's roles grant access to the target XRPC endpoint
  4. If authorized, proxies the request to the PDS with Authorization: Basic using PDS_ADMIN_PASSWORD
  5. Returns the PDS response to the user

4. UI Rendering#

The admin portal uses server-rendered pages that show or hide actions based on the authenticated user's permissions. However, RBAC is always enforced server-side in route handlers regardless of what the UI displays — hiding a button in the template is a convenience, not a security boundary.

Environment Variables#

Variable Required Default Description
GATEKEEPER_ADMIN_RBAC_CONFIG Yes Path to the RBAC YAML configuration file
PDS_ADMIN_PASSWORD Yes PDS admin password used for proxied requests
GATEKEEPER_ADMIN_COOKIE_SECRET No Derived from GATEKEEPER_JWE_KEY 32-byte hex key for signing session cookies
GATEKEEPER_ADMIN_SESSION_TTL_HOURS No 24 Admin session lifetime in hours

Security Considerations#

  • Password isolation: PDS_ADMIN_PASSWORD is never sent to or accessible from browsers. It is only used server-side when proxying authorized requests to the PDS.
  • OAuth security: The OAuth flow uses DPoP binding and PKCE to prevent token interception and replay attacks.
  • Cookie protections: Session cookies are signed (tamper-proof) and set with HttpOnly, Secure, and SameSite=Lax attributes.
  • Server-side enforcement: RBAC is enforced in route handlers, not just in template rendering. Manipulating the UI cannot bypass access controls.
  • Session lifecycle: Sessions expire after a configurable TTL. Expired sessions are cleaned up automatically.
  • Opt-in activation: The admin portal is completely opt-in. If GATEKEEPER_ADMIN_RBAC_CONFIG is not set, no admin routes are mounted and the portal is entirely inactive.

Troubleshooting#

OAuth callback failures Ensure HTTPS is properly configured with a valid certificate, DNS resolves correctly for your PDS hostname, and the hostname the user accesses matches the PDS configuration.

"Access Denied" after login Verify that the DID in your RBAC configuration exactly matches the DID of the authenticating identity. Use goat resolve {handle} to confirm the correct DID.

Session expired Sessions expire after the configured TTL (default 24 hours). Either increase GATEKEEPER_ADMIN_SESSION_TTL_HOURS or log in again.

403 on admin action The authenticated user's roles do not include the endpoint being accessed. Check the members and roles sections of your RBAC config to ensure the required endpoint pattern is granted.

Admin portal not appearing Confirm that GATEKEEPER_ADMIN_RBAC_CONFIG is set in the environment, the file path is correct, and the file exists and is readable by the gatekeeper process.