Select the types of activity you want to include in your feed.
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.
···11+# atproto-auth-proxy
22+33+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.
44+55+## Quick Start
66+77+```bash
88+# 1. Generate a signing key
99+openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -out auth-key.pem
1010+1111+# 2. Run the proxy
1212+AUTH_PRIVATE_KEY=$(cat auth-key.pem) \
1313+AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \
1414+./atproto-auth-proxy
1515+1616+# 3. Update your client-metadata.json (see "Client Metadata Changes" below)
1717+```
1818+1919+## Docker
2020+2121+```bash
2222+# Build
2323+docker build -t atproto-auth-proxy .
2424+2525+# Run
2626+docker run -e AUTH_PRIVATE_KEY="$(cat auth-key.pem)" \
2727+ -e AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \
2828+ -p 8080:8080 \
2929+ atproto-auth-proxy
3030+```
3131+3232+## Deploy to Railway
3333+3434+1. Fork or clone this repository
3535+2. Create a new project on [Railway](https://railway.app)
3636+3. Connect your repository
3737+4. Add environment variables: `AUTH_PRIVATE_KEY` and `AUTH_CLIENT_ID`
3838+5. Set up a custom domain (e.g., `auth.yourapp.com`)
3939+6. Deploy
4040+4141+Railway handles HTTPS and custom domain SSL automatically.
4242+4343+## Environment Variables
4444+4545+| Variable | Required | Default | Description |
4646+|----------|----------|---------|-------------|
4747+| `AUTH_PRIVATE_KEY` | Yes | — | PEM-encoded EC P-256 private key |
4848+| `AUTH_CLIENT_ID` | Yes | — | Your app's OAuth client_id (client-metadata.json URL) |
4949+| `AUTH_KEY_ID` | No | `atproto-auth-1` | JWKS key identifier (`kid`) |
5050+| `AUTH_BIND` | No | `:8080` | Listen address |
5151+| `AUTH_ALLOWED_ORIGINS` | No | `*` | CORS allowed origins |
5252+5353+## How It Works
5454+5555+```
5656+┌─────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
5757+│ Native App │────────>│ atproto-auth-proxy │────────>│ AT Proto Auth │
5858+│ (iOS/Android│<────────│ auth.yourapp.com │<────────│ Server │
5959+│ /Desktop) │ │ │ │ │
6060+│ │ │ Stores: │ │ Validates: │
6161+│ Stores: │ │ - client private key │ │ - client_assertion │
6262+│ - tokens │ │ (env var) │ │ - DPoP proof │
6363+│ - DPoP key │ │ │ │ - refresh token │
6464+└─────────────┘ └──────────────────────┘ └─────────────────────┘
6565+```
6666+6767+The proxy is stateless — no database, no session storage, no user data. It holds a private signing key and uses it to authenticate token requests on behalf of your app.
6868+6969+1. Native app initiates OAuth and gets an auth code
7070+2. App sends the auth code to the proxy (`POST /oauth/token`)
7171+3. Proxy signs a `client_assertion` JWT and forwards the request to the AT Protocol auth server
7272+4. Auth server validates the assertion, issues tokens, and responds
7373+5. Proxy returns the tokens to the app unchanged
7474+6. On refresh: same flow via `POST /oauth/token` with `grant_type=refresh_token`
7575+7676+The proxy also handles Pushed Authorization Requests (`POST /oauth/par`) the same way.
7777+7878+DPoP proofs are generated on the device and forwarded through the proxy transparently.
7979+8080+## API Endpoints
8181+8282+| Method | Path | Description |
8383+|--------|------|-------------|
8484+| `GET` | `/.well-known/jwks.json` | Public key for auth server verification |
8585+| `POST` | `/oauth/token` | Proxy token exchange and refresh requests |
8686+| `POST` | `/oauth/par` | Proxy Pushed Authorization Requests |
8787+| `GET` | `/health` | Health check |
8888+8989+## Client Metadata Changes
9090+9191+Update your app's `client-metadata.json` to use the proxy:
9292+9393+**Before (public client):**
9494+```json
9595+{
9696+ "client_id": "https://yourapp.com/oauth/client-metadata.json",
9797+ "token_endpoint_auth_method": "none"
9898+}
9999+```
100100+101101+**After (confidential client via proxy):**
102102+```json
103103+{
104104+ "client_id": "https://yourapp.com/oauth/client-metadata.json",
105105+ "token_endpoint_auth_method": "private_key_jwt",
106106+ "token_endpoint_auth_signing_alg": "ES256",
107107+ "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json"
108108+}
109109+```
110110+111111+| | Public Client | With Proxy |
112112+|---|---|---|
113113+| Refresh token lifetime | 24 hours | 180 days |
114114+| Session lifetime | 7 days max | Unlimited |
115115+| User re-login frequency | Every 1-7 days | Only when user chooses |
116116+117117+## Key Rotation
118118+119119+1. Generate a new key pair with a new `kid` (e.g., `atproto-auth-2`)
120120+2. Temporarily serve both old and new public keys in the JWKS
121121+3. Deploy — the auth server will fetch the updated JWKS
122122+4. After 24+ hours, remove the old key
123123+5. Update `AUTH_PRIVATE_KEY` and `AUTH_KEY_ID` to the new key only
124124+125125+## Security Considerations
126126+127127+- **Token endpoint validation**: The proxy validates that upstream URLs use HTTPS and rejects private/localhost addresses to prevent SSRF
128128+- **No token logging**: Token values, auth codes, and refresh tokens are never logged
129129+- **HTTPS required**: The proxy must be served over HTTPS in production (handled automatically by Railway/Fly.io)
130130+- **DPoP passthrough**: The proxy never sees DPoP private keys — proofs are between the device and auth server
131131+- **Stateless**: No database, no user data stored — the only secret is the client signing key in an environment variable
132132+133133+## License
134134+135135+[MIT](LICENSE)