# auth.md

Agent authentication for **tieredtravel.com**.

**You do not need to authenticate.** Every guide page and every read endpoint
on this site is public and always will be. Authentication here buys exactly
one thing: an identity, and therefore a rate-limit bucket of your own instead
of one shared with everything else behind your IP.

The only thing on this site that is not public is a person's own trips and
saved cities in the TieredTravel app, and no token, page or API exposes those
to anyone but that person.

If that is not a problem you have, stop reading and send requests.

---

## Who this is for

Any automated client reading TieredTravel: retrieval agents grounding an
answer, crawlers building an index, scripts calling the JSON API, or an MCP
server wrapping the site.

## What a token gets you

| | Anonymous | Authenticated |
|---|---|---|
| Rate limit | 200 requests/min, shared per IP | 1000 requests/min, yours alone |
| Data | all of it | the same, all of it |

There is no tier of content behind the token. A scope exists (`api:read`)
because a token should say what it is for, not because anything is gated on
it.

## Registration

Open, self-serve, no approval, no email, no initial access token. Registration
is [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591) dynamic client
registration.

```bash
curl -s -X POST https://tieredtravel.com/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{"client_name":"my-agent"}'
```

```json
{
  "client_id": "tt_…",
  "client_secret": "…",
  "grant_types": ["client_credentials"],
  "token_endpoint_auth_method": "client_secret_basic",
  "scope": "api:read",
  "client_secret_expires_at": 0
}
```

Store both. **The secret is shown once and cannot be recovered or reset** —
this server keeps no client records (see *How this is built*). If you lose
it, register again; the abandoned client costs nothing.

## Getting a token

`client_credentials` is the only grant. People can have accounts in the app,
but those accounts are not an OAuth resource: no token reaches them, so there
is nothing for an interactive grant to obtain consent for, and
`/oauth/authorize` exists only to return a spec-shaped
`unsupported_response_type` rather than a 404.

```bash
curl -s -X POST https://tieredtravel.com/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials
```

```json
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600, "scope": "api:read" }
```

Credentials may also be sent as `client_id` and `client_secret` form fields.

## Credential use

Send the token as a bearer credential on API requests:

```bash
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://tieredtravel.com/api/v1/walks
```

Two things worth knowing:

- **An invalid or expired token is not an error.** It means "anonymous". The
  request still succeeds at the shared rate limit rather than failing, so an
  agent whose token lapses mid-run keeps working. Refresh by requesting
  another token; there is no refresh grant because there is nothing to
  refresh.
- **Do not send the header when fetching HTML pages.** Pages are identical for
  every client, and an `Authorization` header bypasses the page cache — you
  get a slower response and we get an origin hit, for nothing. Use it on
  `/api/v1/*`, where the quota is what you are after.

## Discovery

| Document | URL |
|---|---|
| Authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)) | `/.well-known/oauth-authorization-server` |
| Protected resource metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) | `/.well-known/oauth-protected-resource` |
| Signing keys (JWKS) | `/.well-known/jwks.json` |
| API catalog ([RFC 9727](https://www.rfc-editor.org/rfc/rfc9727)) | `/.well-known/api-catalog` |

Tokens are Ed25519-signed JWTs (`EdDSA`), audience
`https://tieredtravel.com`, one hour. The audience is the whole origin rather
than the API path because the rate limit a token buys applies to every route,
not only `/api/v1`. Verify them against the JWKS if you care to; nothing
requires you to.

```json agent_auth
{
  "skill": "https://isitagentready.com/.well-known/agent-skills/auth-md/SKILL.md",
  "register_uri": "https://tieredtravel.com/oauth/register",
  "identity_types_supported": ["anonymous"],
  "anonymous": {
    "credential_types_supported": ["oauth2_client_credentials"],
    "claim_uri": "https://tieredtravel.com/oauth/register"
  },
  "registration_methods": [
    {
      "type": "oauth2_dynamic_client_registration",
      "spec": "https://www.rfc-editor.org/rfc/rfc7591",
      "register_uri": "https://tieredtravel.com/oauth/register",
      "token_uri": "https://tieredtravel.com/oauth/token",
      "issuer": "https://tieredtravel.com",
      "grant_types_supported": ["client_credentials"],
      "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
      "credential_types_supported": ["client_id_secret"],
      "scopes_supported": ["api:read"],
      "requires_approval": false,
      "requires_human_identity": false
    }
  ]
}
```

`identity_types_supported` is `anonymous` because that is the truth:
registration asserts nothing about who you are and verifies nothing. No
ID-JAG or verified-email flow is offered, and none is claimed above.

## How this is built, and what it costs you

This authorization server **stores nothing**. Your client secret is derived
from your client id with a MAC, and the signing key is derived from the same
server secret, so registration writes no row anywhere.

The consequence you should know about: **an individual client cannot be
revoked.** Rotating the server secret invalidates every client and every
token at once, and that is the only revocation mechanism there is. This is an
acceptable trade here solely because a token grants a rate-limit tier and no
data access whatsoever. If this service ever puts anything private behind a
token, that design has to change first.

## /auth/signup is for people, not agents

`POST /auth/signup`, `/auth/login`, `/auth/logout`, `/auth/delete`,
`/auth/forgot` and `/auth/reset` are the TieredTravel app's sign-in and
password-reset forms, and `GET /auth/google` starts "Continue with Google"
for a person in a browser. They take HTML form posts from a browser on
this origin, answer with redirects and set a session cookie; cross-site posts
are refused and attempts are rate-limited. A session sees one person's own
trips and saved cities and nothing else.

They are unrelated to OAuth and give an agent nothing: the catalogue is public
without them. Register at `/oauth/register` instead.

## What else is worth knowing

- **Read pages as Markdown.** Every page answers `Accept: text/markdown` with
  a clean Markdown rendering — typically 4x to 25x cheaper in tokens than the
  HTML. No token needed.

  ```
  curl -H 'Accept: text/markdown' https://tieredtravel.com/destinations/barcelona
  ```

- **Enumerate what exists** via the JSON API, described at
  [`/openapi.json`](https://tieredtravel.com/openapi.json) and documented at
  [`/api-docs`](https://tieredtravel.com/api-docs).

- **Check what you may do with it.** Usage preferences are declared as
  Content-Signal directives in
  [`/robots.txt`](https://tieredtravel.com/robots.txt):
  `search=yes, ai-input=yes, ai-train=no`. Reading and citing are welcome;
  training on this content is not. A token does not change that.

- **Identify yourself in `User-Agent`** with a contact URL. It buys nothing
  mechanically, but it makes a rate-limit problem a conversation instead of a
  block.

- **`/go/*` are tracked affiliate redirects.** Do not follow them to
  enumerate destinations: each one records a click that did not happen. They
  are `Disallow`ed in robots.txt.

---

*Last reviewed: September 2026.*
