Hardening an Instance
A HappyView instance holds OAuth credentials for its users and will act with them on behalf of the applications you register. This guide is about narrowing that authority: what each setting actually grants, and why the narrow option is worth the extra work.
The recurring theme is that a scope you grant is a scope some application can use, and several of these settings cannot be changed after the fact without re-consent. Getting them narrow the first time is much cheaper than widening and re-narrowing later.
Scope your API clients
An API client's registered scopes are the ceiling on what any token issued to it can carry. HappyView refuses to issue a token carrying scopes the client is not registered for, so this is the single most effective control you have.
Prefer granular scopes to transition:generic
transition:generic is the broad legacy grant. It covers:
- every
repooperation, on every collection - every
blobupload, of every type - every
rpccall whose method is not underchat.bsky.
It does not cover identity or account operations — those always need explicit scopes, even for a client holding it. That is a genuine limit, but it is the only one.
Granular scopes name what your app actually does:
atproto repo:com.example.post repo:com.example.like blob:image/*repo:<nsid>grants create, update and delete on that collection. Narrow further with repeated?action=parameters:repo:com.example.post?action=create&action=update.blob:<type>/<subtype>grants uploads of that media type.blob:image/*is a reasonable middle ground;blob:*/*is not much better than nothing.rpc:<nsid>?aud=<did>grants calling a method against a service. The audience is required.?aud=*permits any service — prefer a concretedid:web:example.com#servicewhere you know it.
atproto is required on every request and is always implied.
Use permission sets for anything non-trivial
A permission-set lexicon lets you declare a group of permissions once and reference it as include:<nsid>:
atproto include:com.example.appAccessTwo properties make this safer than it looks:
- Authority containment. A permission set may only grant NSIDs under its own authority group.
com.example.appAccesscan grantcom.example.*and nothing else. Publishing a permission set is therefore not a way to vouch for somebody else's collections. - No pinned audiences. An
rpcpermission inside a set may declareaud: "*"or inherit the audience from theinclude:scope, but may not name a concrete service itself. A set cannot quietly aim your users' credentials at a service of its own choosing.
Containment is all-or-nothing per permission entry. An entry listing both your own collection and someone else's grants neither — split them into separate entries, and declare the foreign collection directly in the client's registered scopes instead.
Lock down the XRPC proxy
Any XRPC method with no lexicon registered on your instance falls through to the proxy. By default the proxy is open: it will attempt to serve any NSID a caller names.
Under Settings → XRPC Proxy you can change this to:
- Disabled — only locally registered lexicons are served. The strongest setting, and correct if your instance serves only its own lexicons.
- Allowlist — only NSIDs matching a listed pattern are proxied. Patterns may be exact (
com.example.feed.getHot) or wildcards (com.example.*). - Blocklist — everything except the listed patterns.
Allowlist mode is the one to reach for. Enumerate the NSIDs your app actually calls and list those. It is more work up front, and it means a new dependency fails visibly at deploy time rather than silently widening what your instance will fetch on a caller's behalf.
Choose a routing mode deliberately
Separately from whether a method may be proxied, Routing decides where it goes. The two are independent axes.
- Lexicon authority (the default) resolves the NSID's authority via DNS and forwards there, unauthenticated. This answers "who defines this schema", not "who serves this request". For
com.atproto.repo.*those have different answers: the authority is whoever publishes thecom.atprotolexicons, which has no access to your users' repos, so such requests arrive unauthenticated and are refused. - Service proxy forwards to the caller's own PDS, authenticated as them, following the service proxying model. Absent an
atproto-proxyheader the PDS handles the request itself — which is what makescom.atproto.repo.createRecordwork through HappyView. With one, the header is passed through for the PDS to relay onwards.
Service proxy is the correct model, and the default will change in a future release. Two consequences to weigh before switching:
- Anonymous unrecognized queries stop working. Under authority routing an unauthenticated caller can hit an unrecognized NSID and get an answer. Under service proxy there is no PDS to forward to, so it returns 401. If clients rely on that, they need to authenticate first.
- Authenticated clients reach more. Requests are forwarded with the user's token, so what a client can do is bounded by that token's scopes rather than by which lexicons you have registered. Scope your API clients before switching, per the section above.
HappyView additionally checks repo writes and blob uploads against the session's granted scopes before forwarding. That is a pre-flight check, not the boundary — the PDS enforces scopes regardless — and it deliberately covers only operations that map unambiguously to a permission. It is not a substitute for scoping clients properly.
Scope linked repos narrowly, because you cannot edit them
A linked repo is an admin-managed OAuth grant over somebody's repo. Its scopes are immutable by design — changing them means revoking the grant and creating a new one, which requires the account holder to complete the invite again.
That immutability is what makes the advertised scope union safe to recompute live, so it is not going away. The practical consequence: decide the scopes before you send the invite. A grant created with repo:* to "get it working" is a grant you will have to ask someone to re-authorize in order to narrow.
Two rules worth knowing before you write one:
putRecordis an upsert, so it needs bothcreateandupdateunless the caller suppliesswap_cid. Passingswap_cidis a real no-create guarantee, which narrows the requirement toupdatealone.- Repo reads need no scope at all. There is no read permission in the grammar because repo records are public.
Back up the OAuth client-authentication key
Confidential OAuth clients (the primary instance identity, each registered
domain, and any API client with private_key_jwt authentication configured)
sign every login and every token refresh with an ES256 key HappyView holds
in happyview_oauth_client_keys. Every session is pinned, at the moment it
is established, to the exact kid that signed it — a refresh is only ever
attempted with that same key, never with whatever key happens to be current
later.
Before this pinning existed, losing that key was cheap: generate a new one
and carry on, since nothing depended on which key had signed a session in
the past. That is no longer true. Losing the row for a key that sessions
are pinned to destroys every one of those sessions permanently — there is
no key to sign their next refresh with, and the authorization server has no
way to accept one signed by a substitute. This applies to a retiring key
just as much as to current: rotating a key deliberately keeps the old one
around specifically so its existing sessions keep working, and that only
holds if the row survives.
happyview_oauth_client_keys therefore needs no special backup procedure of
its own — it needs the same database backup you already take, taken
seriously. There is nothing further to configure: it lives in the same
database as every other table this guide assumes you are backing up, and
restoring that backup restores it along with everything else.
Understand what atproto-proxy forwarding means
When a request carries an atproto-proxy header, it is asking to be relayed onwards to a named service. HappyView forwards that header to the user's PDS rather than resolving it — the token that accompanies a relayed request is signed by the user's own identity key, which only their PDS holds.
The consequence worth internalising: the PDS will mint an identity-bearing assertion addressed to whatever service the caller names. Its audience binding stops that assertion being replayed elsewhere, but the named service still receives proof of that user's identity.
There is no destination allowlist in HappyView for this, and that is deliberate — the decision of whether to sign for a given audience belongs to the party holding the key, which is the PDS. What is in your hands is who gets to make such a request at all: registering a third-party API client is the act that grants it. Register clients deliberately, and scope them as above.
A checklist
- Every API client registered with granular scopes, or a permission set, rather than
transition:generic -
blob:scopes narrowed to the media types the app uploads -
rpc:scopes given a concreteaudwherever the service is known - XRPC proxy set explicitly — allowlist if you can enumerate your NSIDs, disabled if you serve only your own
- Linked-repo grants scoped before the invite goes out, not after
- Third-party API clients reviewed: each one can ask the PDS to sign for an audience of its choosing
- Database backups cover
happyview_oauth_client_keys— losing a row there permanently destroys every session pinned to it
Related
- API Clients — creating clients and choosing a type
- Linked Repos — the grant and invite lifecycle
- Permissions — dashboard user permissions, which are separate from OAuth scopes