# Authentication — setup guide

Three auth modes exist. Use **Application Passwords** unless you have a reason not to.

| Mode | Setup effort | Lifetime | Works with automation |
|---|---|---|---|
| Anonymous | none | — | Public endpoints only |
| **App Password** | 2 min | long-lived, revocable | **✅ best path** |
| Cookie + Nonce | moderate | ~12 hours | awkward, needs re-login |

---

## 1. Anonymous

No setup. You can call any public endpoint directly. Example:

```bash
curl 'https://grantd.com.au/wp-json/wp/v2/grant?per_page=5&_fields=id,title,link'
```

What you can do anonymously:

- List/search/paginate grants by title and URL (no body)
- Enumerate taxonomy vocabularies
- Fetch pricing (`/pmpro/v1/membership_levels`)
- Read sitemaps

What you can't: anything under `/grantd/v1/`, `/yoast/v1/meta/search`, any grant body or ACF field, any per-user state.

---

## 2. Application Password (recommended)

WordPress has native Application Passwords since 5.6. They send as HTTP Basic, work on every endpoint cookie auth works on, and can be revoked independently of the user's main password.

### Step 1 — get a paid Grant'd account

Sign up at one of:

- **Free Starter** — https://grantd.com.au/membership-checkout/?level=5 — A$0, 5 matches, no search/filter. Fine for smoke-testing.
- **Seeker annual** — https://grantd.com.au/membership-checkout/?level=4 — A$201.82/yr, full access.
- **Seeker quarterly** — https://grantd.com.au/membership-checkout/?level=3 — A$53.64/quarter.
- **Enterprise** — contact sales. Unlimited matches, presumably higher API quota, partner integration.

The Starter tier is enough to prove an integration works. For real use, pay.

### Step 2 — generate an Application Password

1. Log in to https://grantd.com.au/
2. Navigate to your profile. In standard WordPress this is `/wp-admin/profile.php`. On Grant'd the profile UI may be themed — look for "Application Passwords" or similar.
3. In the **Application Passwords** section, enter a name (e.g. `ai-agent`) and click **Add New Application Password**.
4. Copy the 24-character token. **It's shown once.** Format: `xxxx xxxx xxxx xxxx xxxx xxxx` (spaces optional; WP strips them).
5. The token is revocable from the same page.

If the Seeker tier doesn't grant `/wp-admin/` access, ask Grant'd support to generate one, or fall back to cookie + nonce (section 3).

### Step 3 — use it

The Authorization header is standard HTTP Basic:

```
Authorization: Basic base64("username:app_password")
```

**curl:**

```bash
USER='you@example.com'
APP_PW='xxxxxxxxxxxxxxxxxxxxxxxx'

curl -u "$USER:$APP_PW" \
  'https://grantd.com.au/wp-json/pmpro/v1/me'
```

**Python (stdlib):**

```python
import base64, urllib.request, json

USER = "you@example.com"
APP_PW = "xxxxxxxxxxxxxxxxxxxxxxxx"
token = base64.b64encode(f"{USER}:{APP_PW}".encode()).decode()

req = urllib.request.Request(
    "https://grantd.com.au/wp-json/pmpro/v1/me",
    headers={"Authorization": f"Basic {token}", "Accept": "application/json"},
)
print(json.load(urllib.request.urlopen(req)))
```

**JavaScript (browser or Node):**

```js
const token = btoa(`${username}:${appPassword}`);
const res = await fetch("https://grantd.com.au/wp-json/pmpro/v1/me", {
  headers: { Authorization: `Basic ${token}`, Accept: "application/json" },
});
console.log(await res.json());
```

### Security hygiene for app passwords

- Treat them like API keys: never commit, never log, never paste in chat.
- Store in an environment variable, a secret manager, or (for client-side demos) `sessionStorage` rather than `localStorage`.
- Rotate every few months, or whenever a device that held one is retired.
- Revoke from the WP profile page immediately if leaked.
- **Do not share an app password across integrations.** Each integration gets its own, so you can revoke one without breaking the others.

---

## 3. Cookie + Nonce (fallback)

Use this only if Application Passwords are unavailable.

### How it works

When a browser renders a logged-in page, WordPress injects a short-lived nonce into the HTML:

```html
<script id="wp-api-request-js-extra">
  var wpApiSettings = {
    "root": "https://grantd.com.au/wp-json/",
    "nonce": "57ac64ddb4",
    "versionString": "wp/v2/"
  };
</script>
```

The nonce binds the call to the user's session cookie. Every mutating request must include `X-WP-Nonce: <nonce>`; reads of private data often require it too.

### Flow

1. POST to the login form with the user's credentials and keep the cookie jar.
2. GET any authenticated page (e.g. the user dashboard).
3. Extract `nonce` from `wpApiSettings` in the HTML.
4. Send subsequent API requests with:
   - the session cookies
   - `X-WP-Nonce: <nonce>` header

### Python

```python
import httpx, re

c = httpx.Client(follow_redirects=True)
c.post(
    "https://grantd.com.au/login/",
    data={"log": "you@example.com", "pwd": "password", "wp-submit": "Log In"},
)
html = c.get("https://grantd.com.au/dashboard/").text
nonce = re.search(r'"nonce":"([a-f0-9]+)"', html).group(1)

r = c.get(
    "https://grantd.com.au/wp-json/grantd/v1/dashboard/top-grants",
    headers={"X-WP-Nonce": nonce},
)
print(r.json())
```

### Caveats

- Nonces expire (~12 hours). Re-fetch any authenticated page to refresh.
- If Cloudflare issues a `__cf_bm` challenge, the login POST will fail silently. Use a realistic User-Agent.
- Two-factor / captcha on login is not implemented (as of 2026-04), but that could change.
- No CSRF protection is lost by using this flow yourself, but a malicious third-party website cannot get the nonce because it's behind Same-Origin Policy. **You can't reproduce the nonce cross-origin**, which is the whole point.

---

## Smoke test

Regardless of mode, this is the "am I authenticated?" call:

```bash
curl -u "$USER:$APP_PW" 'https://grantd.com.au/wp-json/pmpro/v1/me'
```

Returns `{ id, email, membership_level: { … } }` if auth is good, `{ code: "rest_forbidden", … }` with status `401` if not.

For the cookie flow, replace `-u "$USER:$APP_PW"` with `-b cookies.txt -H "X-WP-Nonce: <nonce>"`.
