OPNsenseXLII · API and AutomationAPI and automation fundamentals
API authentication and tokens — key/secret pairs, Basic auth, and least privilege for the automation tier
What you'll learn
- Present an OPNsense API key/secret pair correctly as HTTP Basic credentials from curl, Python, and Ansible
- Generate a least-privilege API key from the GUI for an automation task
- Describe the API key lifecycle — creation, rotation, revocation — and the operator discipline each step requires
- Recognise when an automation should use a service account rather than a personal credential
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
The OPNsense REST API serves the same authentication and authorisation model as the web UI. Both surfaces authenticate the same user accounts, both check the same group privileges, and both honour the same source-IP restrictions. The difference is that the API is designed to be driven by automation — scripts, CI jobs, Ansible playbooks, monitoring tools — and that changes the credential model in three ways: credentials must be stored outside source, keys should be scoped to the smallest set of privileges the caller needs, and credentials must be rotatable without rebuilding the calling application.
This lesson covers the single credential mechanism OPNsense supports for the API — a key/secret pair presented over HTTP Basic — how to generate a least-privilege pair from the GUI, the lifecycle of that pair, and the operator discipline that keeps the automation tier secure.
One credential mechanism: a key/secret pair over Basic auth
The API does not accept a user’s login password. It accepts an API key/secret pair that is issued against a user account, and it accepts that pair as HTTP Basic credentials: the API key goes in the Basic-auth username field and the API secret goes in the password field. On the wire that is the standard RFC 7617 header:
Authorization: Basic <base64(key:secret)>
Every HTTP client already knows how to build that header, so no client-specific header assembly is needed:
| Client | How the pair is supplied |
|---|---|
| curl | -u "$KEY:$SECRET" |
Python requests | auth=(key, secret) |
Ansible ansible.builtin.uri | url_username, url_password, force_basic_auth: true |
| Postman | Authorization type Basic Auth, key as Username, secret as Password |
Two shapes people reach for by habit do not work. There is no bearer token: Authorization: Bearer <key> is rejected. There is no bare custom header either: sending the key alone as Authorization: <key>, or in an X-API-Key header, is rejected. Both fail with 401, which is the single most common reason a first API call does not work.
Because the credentials travel on every request, TLS is mandatory. A pair sent over plain HTTP is a pair disclosed to anything on the path.
What is stored where
The key and the secret are handled differently on the firewall:
| Property | API key | API secret |
|---|---|---|
| Role in the request | Basic-auth username | Basic-auth password |
| Storage on the firewall | Held against the user record so it can be looked up | Hashed with the platform password hash |
| Recoverable after creation | Yes — it is visible in the user’s API key list | No — verified with password_verify(), never returned |
| If lost | Look it up in the GUI | Delete the pair and issue a new one |
The consequence is operational: the download the GUI hands over at creation time is the only copy of the secret. A pair whose secret has been mislaid cannot be repaired; it can only be replaced.
Generating a least-privilege key
A least-privilege API key is one that can only do what the caller needs to do. The pair itself has no scope of its own — it carries whatever the owning user carries — so the scoping work happens on the user account. The GUI flow is:
- Navigate to System → Access → Users. Create a new user (or use an existing automation user). The user should be a service account, not a personal account.
- Add the user to one or more groups. Each group has a list of privileges. The API key inherits the union of the user’s group privileges.
- Still in System → Access → Users, edit that user and add an entry under API keys. The browser downloads one ini-formatted file containing
key=andsecret=; move both values into the secrets manager, then delete the download.
$ curl -sk -u "$KEY:$SECRET" -X POST https://localhost/api/auth/user/add -H "Content-Type: application/json" -d '{"user":{"name":"automation-firewall-rules","disabled":"0","descr":"Service account for rule automation"}}'{
"result": "saved",
"uuid": "8c1d2f3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
}Illustrative output
The privileges are the actual access control. The privilege model is the same as the GUI:
| Privilege | What it grants |
|---|---|
page-access.firewall.rule | Read and write firewall rules |
page-access.services.dnsmasq | Manage DNS (Unbound) |
page-status-services | Read service status |
page-diagnostics-logs | Read logs |
page-system-usermanager | Manage users and groups |
page-vpn-wireguard | Manage WireGuard |
page-vpn-ipsec | Manage IPsec |
A monitoring script that only needs to scrape interface counters and PF state should have page-status-services and nothing more. A rule automation that only adds firewall rules should have page-access.firewall.rule and nothing more. The principle: a caller that needs to read counters can never add rules; a caller that needs to add rules cannot read user accounts.
The key lifecycle
A key/secret pair has four states and three operator-driven transitions:
- Creation. The GUI generates a random key and a random secret, offers both as a single ini-formatted download, and keeps only a hash of the secret. The plaintext secret exists from then on solely in that download and in whatever secrets manager the operator pasted it into.
- Active. The pair is in use. Every request carries it, and the firewall verifies the presented secret against the stored hash.
- Rotation. A user can hold several pairs at once, which is what makes rotation safe: issue a second pair, update the secrets manager, redeploy the callers, confirm the new pair is being used, then delete the old one.
- Revocation. The operator deletes the pair from the user’s API key list. Requests presenting it are rejected with 401.
A quarterly rotation schedule is the production default. Rotation on personnel changes (an engineer who had access leaving the team) is mandatory. Rotation on suspicion of compromise is immediate and thorough — assume the leaked pair is in an attacker’s hand, delete it, issue a replacement, and audit for activity authenticated as that pair’s user.
$ curl -sk -u "$KEY:$SECRET" https://localhost/api/core/firmware/status | jq .product_version"25.7.4"Illustrative output
Service accounts vs personal credentials
A service account is a user that exists only to be authenticated by automation. It has no human, no email, no MFA — it is a holder for credentials and group memberships. Two reasons:
- Audit clarity. A log entry attributed to
automation-ansibleis unambiguous about whether a human or a script made a change. A log entry attributed toedson.brandicould be either. - Personnel changes. When an engineer leaves, the personal user is disabled or deleted. The automation that depended on that user is broken. With a service account, the automation is independent of personnel.
The discipline that goes with this: each automation tool gets its own user and its own key. A monitoring scraper, an Ansible controller, a CI pipeline, and a ticketing-integrated webhook should each have separate credentials. A leaked key from one tool is then scoped to that tool’s blast radius, not the whole automation tier.
What to verify before deploying a key
Before a new pair goes into production, three checks:
- The pair works. A first call returns the expected JSON (200 OK). A 401 means the key is wrong, the secret is wrong, the owning user is disabled or expired, or the credentials were not sent as Basic auth.
- The privileges are correct. A test call to a privileged endpoint (rule modification) should succeed; a test call to an unprivileged endpoint (user management) should return 403. The 403 confirms the privileges are bounded — and confirms that 401 and 403 are telling you different things.
- The pair is in the secrets manager. Both halves have been pasted into Vault or the CI secret store, the downloaded credentials file has been deleted, and the local terminal scrollback has been cleared.
A pair that passes all three checks is ready for production. A pair that fails any of them should not be deployed.
Summary
- The OPNsense API accepts one credential type: an API key/secret pair, presented as HTTP Basic with the key as the username and the secret as the password. It is not a bearer token, and the key alone in a custom header does not authenticate.
- The pair is issued against a user account and carries that account’s group privileges. Scoping is done by scoping the user, not the pair.
- The key can be read back from the GUI; the secret is hashed and cannot. The download offered at creation is the only copy of the secret.
- The lifecycle is creation, active, rotation, revocation. A user can hold several pairs at once, so rotation overlaps cleanly. Quarterly rotation is the production default; immediate rotation on personnel change or compromise suspicion.
- One pair per automation tool — not one shared pair across the estate — keeps each tool’s blast radius scoped.
Knowledge check · 5 questions
Q1. An Ansible playbook needs to add firewall rules to an OPNsense firewall. Which credential strategy is the most appropriate?
Q2. A service account is a user account that exists only to be authenticated by automation; it has no human, no email, and no MFA plugin attached.
Q3. Which of these statements about API key lifecycle discipline are correct? Select all that apply.
Q4. You have an OPNsense API key and secret. Which curl invocation authenticates correctly?
Q5. A pair authenticates fine against /api/core/firmware/status but returns 403 on a rule-modification endpoint. What does that tell you?
Passing score: 75%. Answers are checked in this browser.