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#
- Fork or clone this repository
- Create a new project on Railway
- Connect your repository
- Add environment variables:
AUTH_PRIVATE_KEYandAUTH_CLIENT_ID - Set up a custom domain (e.g.,
auth.yourapp.com) - 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.
- Native app initiates OAuth and gets an auth code
- App sends the auth code to the proxy (
POST /oauth/token) - Proxy signs a
client_assertionJWT and forwards the request to the AT Protocol auth server - Auth server validates the assertion, issues tokens, and responds
- Proxy returns the tokens to the app unchanged
- On refresh: same flow via
POST /oauth/tokenwithgrant_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:
- Deploy the auth proxy and verify it's live (
curl https://auth.yourapp.com/health) - Deploy the AASA file to your website
- Wait for Apple CDN propagation — verify via
https://app-site-association.cdn-apple.com/a/v1/yourapp.com - Deploy updated client metadata (
application_type: "web", HTTPS redirect URI) - Build and test the iOS app on a real device (Universal Links don't work in Simulator)
- 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 schemetoken_endpoint_auth_method:"private_key_jwt"back to"none"- Remove
jwks_uriandtoken_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.
- Generate a new key pair with a new
kid(e.g.,atproto-auth-2) - Set
AUTH_OLD_PRIVATE_KEYandAUTH_OLD_KEY_IDto your current key values - Set
AUTH_PRIVATE_KEYandAUTH_KEY_IDto the new key - Deploy — the JWKS now serves both keys; new PAR and token assertions use the active key by default
- After 24+ hours, remove
AUTH_OLD_PRIVATE_KEYandAUTH_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/tokenand/oauth/par(configurable, defaults to 10/min per IP, 100/min global). Proxy headers are only trusted whenAUTH_TRUST_PROXY_HEADERS=true - Stateless: No database, no user data stored — the only secret is the client signing key in an environment variable