Stateless auth proxy that converts AT Protocol native apps from public to confidential OAuth clients. Deploy once, get 180-day refresh tokens instead of 24-hour ones.
Go 99.6%
Dockerfile 0.4%
7 1 0

Clone this repository

https://tangled.org/sparrowtek.com/atproto-auth-proxy https://tangled.org/did:plc:ezer2uh5ki6jvrag5wc5oryk/atproto-auth-proxy
git@tangled.org:sparrowtek.com/atproto-auth-proxy git@tangled.org:did:plc:ezer2uh5ki6jvrag5wc5oryk/atproto-auth-proxy

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz
README.md

atproto-auth-proxy#

A generic, stateless auth proxy that converts any AT Protocol native app from a public OAuth client to a confidential client. Deploy it once and your users get 180-day refresh tokens instead of 24-hour ones — no more forced re-logins.

Quick Start#

# 1. Generate a signing key
openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -out auth-key.pem

# 2. Run the proxy
AUTH_PRIVATE_KEY=$(cat auth-key.pem) \
AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \
./atproto-auth-proxy

# 3. Update your client-metadata.json (see "Client Metadata Changes" below)

Docker#

# Build
docker build -t atproto-auth-proxy .

# Run
docker run -e AUTH_PRIVATE_KEY="$(cat auth-key.pem)" \
           -e AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \
           -p 8080:8080 \
           atproto-auth-proxy

Deploy to Railway#

  1. Fork or clone this repository
  2. Create a new project on Railway
  3. Connect your repository
  4. Add environment variables: AUTH_PRIVATE_KEY and AUTH_CLIENT_ID
  5. Set up a custom domain (e.g., auth.yourapp.com)
  6. Deploy

Railway handles HTTPS and custom domain SSL automatically.

Environment Variables#

Variable Required Default Description
AUTH_PRIVATE_KEY Yes PEM-encoded EC P-256 private key (active signing key)
AUTH_CLIENT_ID Yes Your app's OAuth client_id (client-metadata.json URL)
AUTH_KEY_ID No atproto-auth-1 JWKS key identifier (kid) for the active key
AUTH_OLD_PRIVATE_KEY No PEM-encoded EC P-256 private key (previous key, for rotation)
AUTH_OLD_KEY_ID No JWKS key identifier (kid) for the old key (required with AUTH_OLD_PRIVATE_KEY)
AUTH_BIND No :8080 Listen address
AUTH_ALLOWED_ORIGINS No * CORS allowed origins
AUTH_RATE_LIMIT_PER_IP No 10 Max requests per IP per minute on /oauth/token and /oauth/par (0 to disable)
AUTH_RATE_LIMIT_GLOBAL No 100 Max total requests per minute on /oauth/token and /oauth/par (0 to disable)
AUTH_TRUST_PROXY_HEADERS No false Trust X-Forwarded-For / X-Real-IP for per-IP rate limiting when deployed behind a trusted reverse proxy

How It Works#

┌─────────────┐         ┌──────────────────────┐         ┌─────────────────────┐
│ Native App  │────────>│  atproto-auth-proxy   │────────>│  AT Proto Auth      │
│ (iOS/Android│<────────│  auth.yourapp.com     │<────────│  Server             │
│  /Desktop)  │         │                       │         │                     │
│             │         │ Stores:               │         │ Validates:          │
│ Stores:     │         │ - client private key  │         │ - client_assertion  │
│ - tokens    │         │   (env var)           │         │ - DPoP proof        │
│ - DPoP key  │         │                       │         │ - refresh token     │
└─────────────┘         └──────────────────────┘         └─────────────────────┘

The proxy is stateless — no database, no session storage, no user data. It holds the client signing key material, verifies issuer metadata before each proxied flow, and uses the selected key to authenticate token requests on behalf of your app.

  1. Native app initiates OAuth and gets an auth code
  2. App sends the auth code to the proxy (POST /oauth/token)
  3. Proxy signs a client_assertion JWT and forwards the request to the AT Protocol auth server
  4. Auth server validates the assertion, issues tokens, and responds
  5. Proxy returns the tokens to the app unchanged
  6. On refresh: same flow via POST /oauth/token with grant_type=refresh_token

The proxy also handles Pushed Authorization Requests (POST /oauth/par) the same way.

DPoP proofs are generated on the device and forwarded through the proxy transparently.

The proxy returns the selected signing key via the Auth-Proxy-Key-ID response header. Clients should persist that value and send it back as key_id on later /oauth/token refresh requests so sessions keep using the same key across rotations.

API Endpoints#

Method Path Description
GET /.well-known/jwks.json Public key for auth server verification
POST /oauth/token Proxy token exchange and refresh requests
POST /oauth/par Proxy Pushed Authorization Requests
GET /health Health check

POST /oauth/token#

Request body:

{
  "token_endpoint": "https://bsky.social/oauth/token",
  "issuer": "https://bsky.social",
  "key_id": "atproto-auth-2",
  "grant_type": "refresh_token",
  "refresh_token": "<refresh_token>"
}

key_id is optional, but clients should send it once they have seen an Auth-Proxy-Key-ID response header. During a rotation window, the proxy can also retry an older configured key automatically if the active key returns invalid_client.

POST /oauth/par#

Request body:

{
  "par_endpoint": "https://bsky.social/oauth/par",
  "issuer": "https://bsky.social",
  "key_id": "atproto-auth-2",
  "login_hint": "user.bsky.social",
  "scope": "atproto transition:generic",
  "code_challenge": "<pkce_challenge>",
  "code_challenge_method": "S256",
  "state": "<state>",
  "redirect_uri": "https://yourapp.com/oauth/callback"
}

The proxy validates that issuer, token_endpoint, and par_endpoint match the authorization server’s well-known metadata before forwarding the request.

Client Metadata Changes#

Update your app's client-metadata.json to use the proxy:

Before (public client):

{
  "client_id": "https://yourapp.com/oauth/client-metadata.json",
  "token_endpoint_auth_method": "none"
}

After (confidential client via proxy):

{
  "client_id": "https://yourapp.com/oauth/client-metadata.json",
  "token_endpoint_auth_method": "private_key_jwt",
  "token_endpoint_auth_signing_alg": "ES256",
  "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json"
}
Public Client With Proxy
Refresh token lifetime 24 hours 180 days
Session lifetime 7 days max Unlimited
User re-login frequency Every 1-7 days Only when user chooses

HTTPS Redirect URIs (Required for iOS)#

Bluesky's auth server enforces that application_type: "native" clients must use token_endpoint_auth_method: "none". This means you cannot use the proxy with application_type: "native" — the PAR request will fail with:

{"error":"invalid_client_metadata","error_description":"Native clients must authenticate using \"none\" method"}

To use private_key_jwt, your client metadata must declare application_type: "web", which requires HTTPS redirect URIs instead of custom URL schemes.

The AT Protocol OAuth spec explicitly supports this: native clients are allowed to use an HTTPS URL as long as the URL origin matches the client_id. For example, if your client_id is https://yourapp.com/oauth/client-metadata.json, your redirect URI must be https://yourapp.com/....

Your client metadata should look like:

{
  "client_id": "https://yourapp.com/oauth/client-metadata.json",
  "redirect_uris": ["https://yourapp.com/oauth/callback"],
  "application_type": "web",
  "token_endpoint_auth_method": "private_key_jwt",
  "token_endpoint_auth_signing_alg": "ES256",
  "dpop_bound_access_tokens": true,
  "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json"
}

iOS Setup#

On iOS, HTTPS OAuth callbacks are handled via ASWebAuthenticationSession's HTTPS callback API (iOS 17.4+). You also need an Apple App Site Association (AASA) file for Universal Links as a safety net.

1. Apple App Site Association File#

Serve this at https://yourapp.com/.well-known/apple-app-site-association:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["<TEAM_ID>.<BUNDLE_ID>"],
        "components": [
          {
            "/": "/oauth/callback",
            "comment": "AT Protocol OAuth callback"
          }
        ]
      }
    ]
  },
  "webcredentials": {
    "apps": ["<TEAM_ID>.<BUNDLE_ID>"]
  }
}

Replace <TEAM_ID> with your Apple Developer Team ID and <BUNDLE_ID> with your app's bundle identifier.

Requirements:

  • Must be served with Content-Type: application/json
  • Must not redirect — Apple's CDN fetches it directly
  • Apple caches the file via its CDN; updates can take hours to propagate
  • Verify propagation: https://app-site-association.cdn-apple.com/a/v1/yourapp.com

2. Entitlements#

Add an Associated Domains entitlement to your app (via Xcode: target > Signing & Capabilities > Associated Domains, or via an .entitlements file):

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:yourapp.com</string>
    <string>webcredentials:yourapp.com</string>
</array>

3. ASWebAuthenticationSession#

Use the HTTPS callback initializer instead of the custom-scheme one:

// Before (custom scheme — won't work with the proxy)
let session = ASWebAuthenticationSession(
    url: authURL,
    callbackURLScheme: "yourapp"
) { callbackURL, error in ... }

// After (HTTPS callback — required for the proxy)
let session = ASWebAuthenticationSession(
    url: authURL,
    callback: .https(host: "yourapp.com", path: "/oauth/callback")
) { callbackURL, error in ... }

This requires iOS 17.4+.

4. Remove Custom URL Scheme#

If you were previously using a custom URL scheme (e.g., yourapp://oauth/callback) for OAuth, remove the CFBundleURLTypes entry from your Info.plist. The custom scheme is no longer needed for OAuth callbacks.

5. Web Fallback Page#

Add a simple page at /oauth/callback on your website. ASWebAuthenticationSession intercepts the redirect before it reaches the server, but having a real page there improves the experience for edge cases (e.g., users who land there in a browser):

<h1>Redirecting to YourApp...</h1>
<p>If you're not redirected automatically, open the app on your device.</p>

Deployment Sequence#

Order matters — deploy bottom-up so each layer is ready before the one above depends on it:

  1. Deploy the auth proxy and verify it's live (curl https://auth.yourapp.com/health)
  2. Deploy the AASA file to your website
  3. Wait for Apple CDN propagation — verify via https://app-site-association.cdn-apple.com/a/v1/yourapp.com
  4. Deploy updated client metadata (application_type: "web", HTTPS redirect URI)
  5. Build and test the iOS app on a real device (Universal Links don't work in Simulator)
  6. Submit the app update

Warning: Do not deploy the client metadata update before the AASA file is cached by Apple's CDN. If it isn't, Universal Links won't work and the OAuth callback won't route to your app.

Rollback#

If something goes wrong, revert your client-metadata.json:

  • application_type: "web" back to "native"
  • redirect_uris: HTTPS URL back to custom scheme
  • token_endpoint_auth_method: "private_key_jwt" back to "none"
  • Remove jwks_uri and token_endpoint_auth_signing_alg

The iOS app also needs a new build to switch back to callbackURLScheme. To minimize rollback friction, keep the AASA file deployed permanently — it has no downside even when unused.

Key Rotation#

The proxy supports zero-downtime key rotation. During rotation, both old and new public keys are published in the JWKS so existing sessions bound to the old key continue to work.

  1. Generate a new key pair with a new kid (e.g., atproto-auth-2)
  2. Set AUTH_OLD_PRIVATE_KEY and AUTH_OLD_KEY_ID to your current key values
  3. Set AUTH_PRIVATE_KEY and AUTH_KEY_ID to the new key
  4. Deploy — the JWKS now serves both keys; new PAR and token assertions use the active key by default
  5. After 24+ hours, remove AUTH_OLD_PRIVATE_KEY and AUTH_OLD_KEY_ID

Clients should persist the Auth-Proxy-Key-ID response header from PAR/token responses and send it back as key_id for later token exchanges and refreshes. During the overlap window, the proxy also retries the old configured key automatically if an otherwise-valid token request gets invalid_client from the auth server.

Security Considerations#

  • Issuer metadata validation: The proxy resolves the authorization server’s well-known metadata and verifies the requested token/PAR endpoint matches the declared issuer metadata before sending any signed request
  • Hardened outbound HTTP: Metadata and upstream requests use a public-IP-only transport, reject localhost/private/reserved ranges, and validate redirects to prevent SSRF and DNS rebinding
  • Bounded upstream reads: Metadata and proxied token/PAR responses are size-limited in memory to avoid oversized-response abuse
  • Request timeout: Metadata and upstream requests have explicit timeouts to prevent slow-loris attacks
  • No token logging: Token values, auth codes, and refresh tokens are never logged
  • HTTPS required: The proxy must be served over HTTPS in production (handled automatically by Railway/Fly.io)
  • DPoP passthrough: The proxy never sees DPoP private keys — proofs are between the device and auth server
  • Rate limiting: Per-IP and global rate limits on /oauth/token and /oauth/par (configurable, defaults to 10/min per IP, 100/min global). Proxy headers are only trusted when AUTH_TRUST_PROXY_HEADERS=true
  • Stateless: No database, no user data stored — the only secret is the client signing key in an environment variable

License#

MIT