# Browser keys and allowed domains

> Lock a key to your domains in the dashboard or with PATCH /api/v1/keys/{id}, so it can sit in a public web page without being abused.
>
> https://danadresse.dk/en/guides/browser-noegler-domaener · verified 2026-11-02
An address field that calls the API straight from the browser has to carry the key in the page source. Anyone can open the developer tools and copy it. A browser key is a regular key with a list of allowed domains. The API answers only when the call comes from a page on one of them.

Without the lock the key would also be caught by leak control: a live key used from many different IP addresses in one day is flagged as leaked and blocked after a grace period. A domain-locked key is exempt from that check, because every visitor is expected to have their own IP.

## 1. Create a key for the browser

Create a new key under [Keys](/dashboard/keys) and give it a name that says where it is used, for example "webshop checkout". Never use the same key in the browser and on your server:

- A browser key only works when the call carries an `Origin` or `Referer` header from an allowed domain. Calls from a server have neither and are refused with 403.
- A browser key may only do address lookups (see step 5).

You can also lock a test key (`dawa_test_…`) to `localhost` and use it during development.

## 2. Set the domains in the dashboard

Under each key on [Keys](/dashboard/keys) there is a section called **Tilladte domæner** (allowed domains). Type the domains in the field, separated by commas, spaces or line breaks, and click **Gem** (save). An empty field turns the key back into a regular server key.

You can paste full URLs. `https://www.firma.dk/kassen` is stored as `www.firma.dk`. Port and scheme are dropped.

When the lock turns calls away, the key shows which domains were refused in the last 7 days and how many times, for example `shop.firma.dk (108×)`. If it is your own site, add the domain to the list.

## 3. Or set them with the API

With many keys or sites you can set the list with `PATCH /api/v1/keys/{id}`. The endpoint needs a JWT, not an API key. You get one from `POST /api/v1/auth/login` with your email and password. If you sign in with Google or an emailed code and have no password, set one through [Forgot password](/dashboard/forgot) first.

The script signs in, lists your keys and sets the domains on one of them. It needs `pip install requests` and three environment variables:

```python id=patch-origins run=syntax
import os
import sys

import requests

BASE = "https://api.danadresse.dk"

login = requests.post(
    f"{BASE}/api/v1/auth/login",
    json={"email": os.environ["DANADRESSE_EMAIL"], "password": os.environ["DANADRESSE_PASSWORD"]},
    timeout=10,
)
login.raise_for_status()
auth = {"Authorization": f"Bearer {login.json()['access_token']}"}

# List the keys so you can find the id
for k in requests.get(f"{BASE}/api/v1/keys", headers=auth, timeout=10).json():
    print(k["id"], k["mode"], k["name"], k["allowed_origins"])

key_id = os.environ["DANADRESSE_KEY_ID"]
r = requests.patch(
    f"{BASE}/api/v1/keys/{key_id}",
    headers=auth,
    json={"allowed_origins": ["firma.dk", "www.firma.dk", "*.firma.dk", "localhost"]},
    timeout=10,
)
if r.status_code != 200:
    err = r.json().get("error", {})
    sys.exit(f"{r.status_code} {err.get('code')}: {err.get('message')}")
print("Allowed domains:", r.json()["allowed_origins"])
```

The response holds the list as stored after normalisation:

```json id=patch-response run=no
{
  "id": "4f1c0b7e-2a9d-4c55-9a51-0d3c7f1e8b20",
  "allowed_origins": ["firma.dk", "www.firma.dk", "*.firma.dk", "localhost"]
}
```

`{"allowed_origins": []}` removes the lock. A body without `allowed_ips` leaves any IP list on the key untouched. Invalid domains give 422 with the code `invalid_origins` and an error text in Danish. A key that does not exist, is revoked or belongs to another account gives 404.

## 4. How domains are matched

The API takes the host name from the `Origin` header. If it is missing, `Referer` is used. Scheme and port do not count.

| Pattern | Matches | Does not match |
|---|---|---|
| `firma.dk` | `firma.dk` | `www.firma.dk`, `shop.firma.dk` |
| `www.firma.dk` | `www.firma.dk` | `firma.dk` |
| `*.firma.dk` | `www.firma.dk`, `shop.firma.dk`, `a.b.firma.dk` | `firma.dk` |
| `localhost` | `localhost` on any port | `127.0.0.1` |
| `127.0.0.1` | `127.0.0.1` on any port | `localhost` |

Rules for the list:

- A wildcard may only come first, as in `*.firma.dk`. `*`, `www.*` and `*.*.firma.dk` are refused.
- `*.firma.dk` covers subdomains only. If your site runs on both `firma.dk` and `www.firma.dk`, list both, or `firma.dk` plus `*.firma.dk`.
- At most 40 domains per key and at most 700 characters in total. Use a wildcard instead of many subdomains.
- `localhost` is allowed for development. If you open an HTML file straight from disk (`file://`), the browser sends `Origin: null` and the call is refused. Start a local server instead, for example `npx serve` or `python3 -m http.server`.

## 5. What a browser key may not do

A domain-locked key covers address lookups: `/autocomplete`, `/adresser`, `/adgangsadresser`, `/datavask/adresser`, `/postnumre`, `/kommuner` and the other DAWA-compatible endpoints. It is refused with 403 and the code `browser_key_scope` on:

- `/api/v1/enrich/…` (BBR, DAGI, property, climate etc.)
- `/api/v1/datavask/parse`
- `/api/v1/stats/national`

These call on to public registers and must be called from your server with a server key. If you need them on a web page, build a proxy as in [Next.js route handler as a proxy](/en/guides/nextjs-proxy).

## 6. Limits for a browser key

Calls with a browser key count against the account's monthly quota and per-minute limit, like any other call. On top of that, each visitor IP has its own cap of 120 calls per minute per key. Above it the API answers 429 with the code `rate_limit_per_visitor` and a `Retry-After` header with the seconds until the minute window resets (1-60). That way one visitor cannot use up your whole quota. Refused calls are not counted in your quota.

Responses from the API, errors included, carry CORS headers and expose `Retry-After`, `X-RateLimit-*`, `X-RateLimit-Minute-*` and `X-Quota-*` to JavaScript. After a 429, read `res.headers.get("Retry-After")` and wait that long before the next call. With a debounce of 150-200 ms in your address field a normal user never gets near the cap.

## 7. Test the lock

From a terminal you can set the `Origin` header yourself and check that the lock behaves as you expect. Swap in your browser key:

```bash id=test-origin run=no
# Your browser key
export BROWSER_KEY="…"

# Allowed domain → 200 and a list of suggestions
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "X-Api-Key: $BROWSER_KEY" -H "Origin: https://www.firma.dk" \
  "https://api.danadresse.dk/autocomplete?q=Strandvejen"

# Other domain → 403 origin_not_allowed
curl -s -H "X-Api-Key: $BROWSER_KEY" -H "Origin: https://andet.dk" \
  "https://api.danadresse.dk/autocomplete?q=Strandvejen"
```

The second call returns something like this. The text says which domains the key is locked to and which domain the call came from:

```json id=origin-not-allowed run=no
{
  "type": "ForbiddenError",
  "title": "This API key is locked to firma.dk, www.firma.dk and this request came from andet.dk. Add the domain under API-nøgler → Tilladte domæner, or use a server-side key.",
  "details": null,
  "code": "origin_not_allowed"
}
```

## How much does the lock protect?

`Origin` and `Referer` are set by the browser, and a script on another site cannot change them. Outside a browser anyone can send whatever headers they like, as the curl example shows. The lock therefore stops other people from pasting your key into their own website. It does not stop a script on a server from pretending to be your domain. The per-visitor cap and the account quota are the backstop.

If you think the key is being abused, create a new one, add the same domains, swap it in on the page and revoke the old one. See [Rotate keys](/en/guides/roter-noegler).

## Next steps

- [Address autocomplete widget in plain JavaScript](/en/guides/vanilla-autocomplete-widget)
- [Next.js route handler as a proxy](/en/guides/nextjs-proxy) — when the key must not be in the browser at all.
- [Error 403](/en/guides/fejl-403) — every 403 code and what it means.
