Highport orbital control
Asking visitors to sign in
You can put a lock on part of your site. It sits at the gate, it asks for a name and not a payment, and it is honest with the people it turns away.
Your site record can name path prefixes that only a signed-in reader may read. Everything you do not name stays public. A reader who asks for a gated page is sent to a sign-in form on your own domain, signs in with the AT Protocol account they already have, and comes back to the page they wanted.
The field is access.requireAuth, and it is a list of path prefixes, not a single switch. Name /gallery and everything under /gallery is gated. Name / and the whole site is.
A gate is somewhere Orbital Control asks for a name and nothing else. It takes no payment, it checks against no list, and it tells the people it turns away exactly what happened.
What the field does
access sits at the top level of the space.highport.sites.site record, beside source and redirects. It has one property today.
{
"$type": "space.highport.sites.site",
"source": {
"$type": "space.highport.sites.defs#manifest",
"resources": {
"/": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "text/html",
"size": 2048
}
},
"/gallery/one.jpg": {
"src": {
"$type": "blob",
"ref": { "$link": "bafkrei…" },
"mimeType": "image/jpeg",
"size": 1048576
}
}
}
},
"access": {
"requireAuth": ["/gallery"]
}
}
That record is an example. The field names and the shape are exact.
| Property | Value |
|---|---|
| Field | access.requireAuth |
| Type | array of strings |
| Most prefixes | 64 |
| Longest prefix | 1024 characters |
access absent, or the list empty |
nothing is gated |
Both numbers come from the record's schema, not from anything Highport checks. Neither validation nor the gate counts them, so a list that goes over one is not refused on this side.
Every prefix is normalized to one spelling before it is stored, so the spelling you use does not change what gets gated.
| Written in the record | Stored | Why |
|---|---|---|
/gallery |
/gallery |
already canonical |
gallery |
/gallery |
a leading / is added |
/gallery/ |
/gallery |
trailing slashes are trimmed |
/gallery |
/gallery |
surrounding whitespace is trimmed |
/ |
"" |
the slash is trimmed like any other, and an empty prefix matches every path |
"", or only whitespace |
dropped | it normalizes to nothing |
| anything containing a null, newline or carriage return | dropped | control characters |
anything containing a .. segment |
dropped | a request path never contains one, so it could only ever match nothing |
An entry that cannot be used is dropped and the rest of the record publishes normally. The record is not refused over a stray string in an optional list, with one exception below.
How a prefix is matched
Matching is by path segment, not by string prefix. A stored prefix gates the path that is exactly it, and any longer path whose next character after the prefix is a /.
| Prefix | Request path | Gated? |
|---|---|---|
/gallery |
/gallery |
yes |
/gallery |
/gallery/2026/one.jpg |
yes |
/gallery |
/gallery-public/index.html |
no |
/gallery |
/galleries |
no |
/gallery |
/ |
no |
/ |
anything | yes |
The request path is normalized before it is compared, by the same functions that normalize it when your files are matched to it. The query string is removed, repeated slashes are collapsed and . segments are dropped. So /gallery?x=1, //gallery/one.jpg and /gallery//one.jpg are all gated by /gallery.
What stays public whatever you list
Three things are never gated.
/.well-known/atproto-did is answered to anyone, signed in or not. It is how the network resolves a handle to an account, a resolver cannot sign in, and gating it would only stop your domain working as a handle.
The sign-in pages themselves, meaning everything under /_bard/oauth/ and /_bard/auth/ plus /_bard/denied, are served before the gate is consulted. A site that refuses everything else still has a working sign-in form, and reaching that form is never billed to anybody.
The certificate challenge path, /.well-known/acme-challenge/*, is answered before any of this runs.
Everything else can be gated, including things you may not have thought of as pages. A prefix of / also gates /_bard/site.json and /_bard/params.json, which are how a tool reads your site's structure; /.well-known/rasl/{cid}, which serves any of your files by content address; and /_bard/health and /_bard/version, which report whether the machine serving your site is answering.
One consequence of a / prefix is worth knowing in advance. /_bard/health is also what we ask for on your domain to prompt a certificate before your first visitor arrives, so that request meets your gate like any other anonymous one. It does not fail your registration, and the certificate is issued on the handshake, which has already happened by then. But a whole-site gate has not been tested against it.
Writing it
The Hub has no control for this field, and its site editor will take an existing gate away. There is no switch in the editor, and no view reports whether a site is gated. Neither space.highport.sites.getSite nor space.highport.manage.getDomain carries the field. The editor composes every publish from a fixed set of fields, being the manifest or the tile binding, parameters, redirects, notFound and wellKnown, and access is not among them. So a gated site republished from the editor comes back ungated at the next update, with nothing on the screen to say so.
Today the only way to author a gate, and the only way to keep one, is to write the record yourself, from a shell or a script. Before you start, this is what that route costs.
| What you need | Why |
|---|---|
| An app password on your own account | com.atproto.server.createSession at your PDS turns it into an access token. Both calls below use it, one directly and one to mint the token for the other |
| A service-auth token for the Highport call | com.atproto.server.getServiceAuth at your PDS, with aud=did:web:highport.space and lxm=space.highport.manage.validate. One token authorizes one method, so mint a fresh one for every Highport call you make |
An explicit exp on that token |
At most 300 seconds. A PDS whose default window is wider produces a token that is refused as TokenLifetimeTooLong with nothing else about the request wrong |
Then it is two calls. Validate with space.highport.manage.validate, then write the record the validator handed back with com.atproto.repo.putRecord under the hostname key.
The site record is the shape of the thing you are writing, and Validate before you write is what validation gives back and why you write exactly that. A complete transcript at the foot of it is that sequence as runnable shell: sign in, mint a token, upload the files, validate, write, poll until it settles. A gated record is the ordinary site record with access added and nothing else changed, so that transcript applies unchanged. Put the access block in record.json beside source, and every command after it is the same. XRPC, and building your own tools covers the other two ways to get a token, if an app password is not how you reach your account.
Turning the gate on or off takes effect when the new record activates, plus up to 30 seconds for the gate itself.
What validation refuses
One rule. access.requireAuth may not name /.well-known/atproto-did exactly. Validation answers valid: false with a ReservedPathCollision error:
{
"name": "ReservedPathCollision",
"message": "`access.requireAuth` names /.well-known/atproto-did, which Highport answers to anonymous readers whatever this record says: it is AT Protocol handle resolution, a resolver cannot sign in, and gating it would only stop this domain resolving as a handle (consumptive-billing.md §8.3). Remove the prefix, or name the paths beneath it you did mean to gate"
}
This is the one entry that is refused instead of dropped, because it normalizes to something specific and would otherwise leave you believing you had protected the one path that is guaranteed public. It is checked against the normalized form, so atproto-did, /.well-known/atproto-did/ and a padded spelling are all the same rule.
Prefixes that merely cover that path are fine. / and /.well-known are both accepted, and the handle path keeps answering anonymously underneath them.
What a visitor goes through
The short version: a page request is redirected to a sign-in form on your domain, and anything else gets a 401. Sites that ask who you are is the full account, written for the reader.
| Situation | What the reader gets |
|---|---|
| Not signed in, asking for a gated page | 302 to /_bard/oauth/login?return=<the path>, on your own host |
| Not signed in, asking for a gated image, script or stylesheet | 401, empty body |
| Signed in, gated path | 200 and the ordinary bytes |
| Sign-in did not complete | 400, "That sign-in did not finish" |
The sign-in form is served from your domain and never leaves it. It is a plain server-rendered page with no JavaScript, headed Sign in to read <your domain>, and it asks for a handle or a DID and nothing else. Under the form it says:
Highport asks your server for your identity and nothing else — no access to
your posts, your files, or anything you have published.
That is true, and it is all that is asked for. The one permission requested of the reader's server is atproto, which is identity and nothing else. The consent screen they see next is rendered by their own server and not by us, and it names your domain as the thing they are signing in to. The mark on it and the terms and privacy links are Highport's, because Highport is what operates the sign-in on your behalf.
When they come back, they hold a cookie named __Host-bard-site carrying their account identifier and your domain and nothing else. No token, no key. It is scoped to your host alone, so a reader who signs in on your site is not identified on anybody else's, and by default it lasts 14 days.
Putting a sign-in link on your own page
Nothing is injected into your HTML. If you want a sign-in or sign-out affordance on the page, include the widget yourself:
<span data-bard-auth data-bard-sign-in="Sign in to read the gallery"></span>
<script src="/_bard/auth/widget.js" defer></script>
It fills in every element carrying data-bard-auth and touches nothing else. data-bard-sign-in, data-bard-signed-in and data-bard-sign-out set the three labels, and the defaults are Sign in, the reader's raw DID, and Sign out. Every failure is silent. If the status cannot be read, the affordance simply does not appear.
If you would rather write your own, GET /_bard/oauth/me answers 200 when the reader is signed in and 404 when they are not, with the DID in an X-Bard-Visitor header:
const signedIn = (await fetch('/_bard/oauth/me', { credentials: 'same-origin' })).ok;
What you learn about who visited
Readers who signed in on your domain appear in the Hub under Activity on your sites, in the Visitors table, one row each.

| Column | What it is |
|---|---|
| Visitor | the reader's DID, the permanent identifier behind their handle, shown raw and never resolved |
| Spent | bytes billed to them on this domain |
| Requests | how many requests |
| Last seen | the start of the hour containing their last response, not the exact instant |
The same data is available as space.highport.manage.listSiteVisitors, taking domain, from (default 30 days ago), to (default now), limit (1–100, default 50) and cursor. It requires the domain to be registered to you, and answers InvalidDomain, DomainNotFound or AnalyticsDisabled. The Hub asks for the last 30 days and the top 25 visitors, so on a busy domain the panel is a leaderboard and not a census. The line beneath it, which gives signed-in visits as a share of your requests, is worked out from those same 25 rows and reads low when there are more.
What is never recorded
| Question | Answer |
|---|---|
| Who read this anonymously? | Not recorded. An anonymous request produces a row about a request, never a row about a person. |
| What was their IP address? | Not recorded. The address is deleted before the log line is written. |
| What browser or bot was it? | Classified as a browser, a crawler or unknown, which is the crawler line on your Traffic view. The raw user agent is kept for 30 days for Highport's own crawler review and is not shown to you. |
| Where did they come from? | Not recorded. There is no referrer data. |
| How many unique visitors? | Not answerable. Without an address, two requests from one reader and one request each from two readers look the same, and a user agent is shared by far too many readers to tell them apart. |
| What did one visitor do across my several domains? | Not answerable. The query is scoped to one domain and there is no way to join a visitor across domains, including other domains you own. |
Visitor history cannot be deleted. There is no method and no control for it. The rows sit in an hourly aggregate keyed on the visitor, kept for 400 days, and nothing today removes one. If that matters to you, it should be part of the decision to turn a gate on.
What this is not
Not a paywall. Nobody is asked for money. What a gate changes is whose bandwidth the reading comes out of. A listed prefix is read by a signed-in reader and billed to that reader's own allowance instead of yours, whatever your own budget says, whether or not it is spent, and even on a domain where you pay for everything else. The one exception is the fallback that catches a reader who has spent their allowance, which sends the bill back to you if you asked for it. Everything you did not list is billed the ordinary way. Limits and quotas sets out that arrangement.
It is also the one refusal that does not depend on a deployment's metering being switched on. An owner's budget can be running in a reporting mode where nothing is actually turned away. A listed prefix is enforced regardless.
Not a member list. There is no allow list, no invitation and no membership check. Anyone with an AT Protocol account can sign in and read a gated path. Gating on membership of a space is not available. Nor is there a way to sign one reader out again or to withdraw one reader's access. What you can do is stop listing the prefix, which lets everybody back in.
Not encryption, and not a guarantee about the file. This is a check made at the gate before the bytes are sent, and it is only as strong as the gate. Two consequences are worth knowing before you rely on it.
A gated response carries the ordinary Cache-Control: public, max-age=60 and no Vary: Cookie. The bytes do not vary by who asked; only the decision to send them does. A shared cache in front of your site could therefore hand a gated file to an anonymous reader for up to a minute.
And the check fails open. If the component that answers it cannot be reached, requests are served, gated paths included, instead of the site going dark. That is the right trade for a static site, and it means a gate is not the place to put something that would genuinely harm you if it were read.
Sites published from a space
A gate works identically on a site published from an atproto space. The same field, the same matching, the same enforcement. Nothing after the write knows or cares which of the two the record came from.
What differs is the writing. The record goes to the space instead of to your own repository, which means com.atproto.space.putRecord at the space's authority and not com.atproto.repo.putRecord. Validation works the same way and returns the same ReservedPathCollision for the same mistake, though it always answers prepared: false for a space record.
One thing you cannot do there is a conditional update. com.atproto.space.putRecord takes no swapRecord, so the safe republish, meaning write this only if the record is still the one I validated, is not available for a site kept in a space. Highport's own write route refuses the field outright instead of dropping it, with a 400 InvalidRequest reading a conditional update is not available for a record in a space, because a caller who asked for that guarantee and did not get it would believe it held one. To remove a gate from a space-hosted site, publish the record again without the access field.
Spaces covers the rest of what a space changes.
Site owners · 9 of 12