This lab makes you fluent with the OPNsense REST API for the most common write operation: creating an alias. You will generate a least-privilege API key, build a script that creates an alias with the disciplined search-then-add-then-verify pattern, confirm the alias is loaded into PF, and then roll it back. The discipline on display is the production one: every write is preceded by a read, every write is followed by a verify, and every change has a rollback path tested before the change is committed.
By the end you will have a script that an automation platform could call from a CI pipeline, and you will have the discipline to know what the script is doing and why each step matters.
Objective
By the end of this lab, you can:
- Generate a least-privilege service account and API key in the GUI.
- Verify the key works with a read-only API call before any write.
- Create an alias via the API with the search-then-add-then-verify pattern.
- Confirm the alias is loaded into PF and visible to
pfctl. - Delete the alias via the API and confirm the rollback.
Requirements
- An OPNsense instance you can reach via SSH and the API (HTTPS, default port 443).
- A workstation with
curlandjqinstalled. - Permission to read the API documentation in the GUI (System → Access → Users and System → API).
- A secrets manager or a local file with
chmod 600permissions to hold the API key. Never commit the key to git.
Tasks
Task 1: Create the service account
In the GUI, navigate to System → Access → Users → Add.
- Username: lab-automation-alias
- Password: a long random value — the API never uses it, but the field is required
- Description: Service account for alias automation (lab)
- Shell: /sbin/nologin (this account is for API only, not interactive login)
Save the user.
Task 2: Add the user to a privilege group
The service account needs the privilege to manage aliases. The cleanest pattern is to assign the privilege directly to the user (via the Effective Privileges tab) rather than creating a new group for one user.
Navigate to System → Access → Users → lab-automation-alias → Effective Privileges. Add the privilege:
page-access.firewall.alias— read and write aliases
Do not add any other privilege. The principle is: the account can do exactly what the caller needs to do and nothing more.
Task 3: Generate the API key and secret
Navigate to System → Access → Users → lab-automation-alias and add an entry under API keys.
The browser downloads a single ini-formatted file containing two lines — the key and the secret:
key=w86XNZob8Oq8aC5hxh2hevLN00r0kbNarNtdpoQU781fyoeaOBQsBwkXUt
secret=puOyw0Ega3xZXeD26XVrJ5WYFepOseySWLM53pJASeTA3
The key can be read back from the GUI later; the secret cannot, because the firewall stores only a hash of it. That download is the only copy.
Move the file somewhere with restrictive permissions:
mkdir -p ~/.opnsense-lab
mv ~/Downloads/apikey.txt ~/.opnsense-lab/alias-api-key
chmod 600 ~/.opnsense-lab/alias-api-key
ls -l ~/.opnsense-lab/alias-api-key
The output should show -rw-------. Anyone who can read this
file has exactly the power the automation has. Treat it like a
private SSH key.
Load both halves into the shell. The file is key=value pairs,
so it can be sourced directly:
set -a; . ~/.opnsense-lab/alias-api-key; set +a
KEY="$key"; SECRET="$secret"
# Substitute your own firewall address before running:
FIREWALL=https://192.0.2.1
Task 4: Verify the pair works with a read-only call
Before any write, verify the credentials work. The safest first call is a read-only one that returns information about the running firewall:
curl -sk -u "$KEY:$SECRET" "$FIREWALL/api/core/firmware/status" | jq .
-u is the whole authentication story. curl builds an
Authorization: Basic <base64(key:secret)> header from it,
which is exactly what OPNsense expects — the key as the
username, the secret as the password. There is no bearer token
and no custom header; sending the key alone in an
Authorization header returns 401.
The response should be a JSON object including
product_version and os_version. If the call returns 401,
the key or the secret is wrong — re-check both against the
downloaded file. If it returns 403, the credentials
authenticated but the account lacks the privilege for that
endpoint; re-check the privilege assignment.
That distinction is worth internalising now, because it saves most of the debugging later: 401 means the credentials were not accepted; 403 means they were, and the user is not allowed to do this.
Test the alias endpoint specifically:
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/searchItem" | jq '.total'
The response is the number of existing aliases.
Task 5: Snapshot the existing aliases
Before any write, snapshot what exists. The diff between before and after is the proof the change did what you intended.
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/searchItem" > /tmp/aliases-before.json
jq -r '.rows[] | "\(.name)|\(.type)|\(.description)"' \
/tmp/aliases-before.json | sort > /tmp/aliases-before.txt
wc -l /tmp/aliases-before.txt
The output is the number of aliases on the firewall. You will compare this against the count after the lab.
Task 6: Verify the alias name does not exist yet
The disciplined pattern is to search for the alias before adding it. A duplicate name is a soft error that the API might handle silently (the response is a UUID, but the alias might not actually be created if the validation rejected the duplicate).
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/getAliasUUID/lab_test_alias" | jq .
getAliasUUID maps an alias name to its UUID, which makes it a
convenient existence check: an alias that does not exist comes
back with an empty uuid. If it comes back populated, the name
is taken — pick a different one or delete the existing alias
before continuing.
Task 7: Create the alias
The endpoint for adding an alias is
/api/firewall/alias/addItem. Note the field name:
description, not descr — the alias model defines
description, and an unknown field is ignored rather than
rejected, so getting it wrong saves an alias with no
description and reports success.
curl -sk -u "$KEY:$SECRET" -X POST \
-H "Content-Type: application/json" \
-d '{
"alias": {
"name": "lab_test_alias",
"type": "host",
"enabled": "1",
"description": "Lab test alias — single host, safe to delete",
"content": "192.0.2.50"
}
}' \
"$FIREWALL/api/firewall/alias/addItem"
The response should be a JSON object with "result": "saved"
and a UUID for the new alias:
{
"result": "saved",
"uuid": "f1e2d3c4-b5a6-7890-1234-567890abcdef"
}
Save the UUID — every later call addresses the alias by it:
ALIAS_UUID=$(curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/getAliasUUID/lab_test_alias" | jq -r '.uuid')
echo "alias UUID: $ALIAS_UUID"
Now apply the change. Saving wrote the alias to the configuration; the pf table behind it does not exist yet:
curl -sk -u "$KEY:$SECRET" -X POST \
"$FIREWALL/api/firewall/alias/reconfigure"
Task 8: Verify the alias is in the configuration
A saved response from addItem is not enough. It says the
alias is in the configuration; it says nothing about whether
the pf table behind it exists. Verify in three places.
Step 1: The alias is in the configuration.
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/getItem/$ALIAS_UUID" | jq .
The response should show the alias with all the fields you set.
Check description in particular — if it is empty, the payload
used the wrong field name.
Step 2: The alias is a pf table, read through the API.
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias_util/list/lab_test_alias" | jq '.rows'
The response should list 192.0.2.50. This reads the table pf
is actually holding, not the configuration — which is the whole
point of the step.
Step 3: The same table, read on the firewall itself.
The pf table takes the alias’s name, so from a shell on the firewall:
pfctl -t lab_test_alias -T show
The output should show 192.0.2.50. If step 1 succeeds and
steps 2 and 3 come back empty, the alias was saved but never
applied — go back and run reconfigure.
Task 9: Use the alias in a temporary rule
The alias is created but not used. The production-grade verification is to use it in a rule and confirm the rule matches the expected traffic.
Navigate to the GUI and add a temporary rule that references the alias:
- Interface: LAN
- Source: lab_test_alias
- Destination: any
- Action: pass
- Description: Lab test rule using lab_test_alias
Apply the change. From a host at the IP 192.0.2.50, generate
traffic:
curl --max-time 5 https://example.com/ > /dev/null && echo "OK"
Confirm the state was created:
pfctl -s state | grep 192.0.2.50
The state confirms the rule matched (the alias resolved to the IP, the rule permitted the traffic, and PF created state).
Task 10: Delete the alias via the API
The lab is now ready for rollback. Delete the alias:
curl -sk -u "$KEY:$SECRET" -X POST \
"$FIREWALL/api/firewall/alias/delItem/$ALIAS_UUID"
The UUID goes in the path, not the body. The response should be
a JSON object with "result": "deleted".
If instead you get an error naming what is using the alias, the test rule from Task 9 is still referencing it — the API refuses to delete an alias that is in use, which is a guard rail rather than a fault. Remove the rule first (Task 12), then come back.
Then apply, so the table goes away as well as the record:
curl -sk -u "$KEY:$SECRET" -X POST \
"$FIREWALL/api/firewall/alias/reconfigure"
Task 11: Verify the alias is gone
Wait a few seconds for the resolver to reload, then verify in all three places.
Step 1: The alias is not in the configuration.
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/getAliasUUID/lab_test_alias" | jq .
The uuid should come back empty.
Step 2: The pf table is gone.
pfctl -t lab_test_alias -T show
pf should report that no such table exists. An empty table that still exists means the delete saved but was not applied.
Step 3: The alias is absent from the listing.
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/searchItem" \
| jq '.rows | map(select(.name == "lab_test_alias")) | length'
The result should be 0.
Task 12: Remove the test rule
The lab rule from Task 9 is still in the firewall. Remove it from the GUI:
- Firewall → Rules → LAN → find the rule with description “Lab test rule using lab_test_alias” → delete → apply
Verify the rule is gone:
pfctl -s rules | grep lab_test_alias
The output should be empty.
Task 13: Snapshot the aliases after the lab
curl -sk -u "$KEY:$SECRET" \
"$FIREWALL/api/firewall/alias/searchItem" > /tmp/aliases-after.json
jq -r '.rows[] | "\(.name)|\(.type)|\(.description)"' \
/tmp/aliases-after.json | sort > /tmp/aliases-after.txt
diff /tmp/aliases-before.txt /tmp/aliases-after.txt
The diff should be empty. The alias was created and removed without leaving any residue.
Validation
- A service account with the minimum privilege (
page-access.firewall.alias) is created and an API key/secret pair is generated. - The pair is verified with a read-only call, using Basic auth, before any write.
- The alias is created via the API and applied with a separate
reconfigurecall. - The alias is confirmed in the configuration and in the running pf table.
- A temporary rule using the alias matches traffic correctly.
- The alias is deleted via the API and confirmed gone in all three places.
- The diff between the pre-lab and post-lab alias lists is empty.
Cleanup
The lab is largely self-cleaning. The remaining cleanup is the service account and the API key.
# Remove the service account
# GUI → System → Access → Users → lab-automation-alias → delete
# Confirm the user is removed
# Remove the local API key file
shred -u ~/.opnsense-lab/alias-api-key
# Remove the temp test directory
rm -rf ~/.opnsense-lab
To verify the service account is gone:
curl -sk -o /dev/null -w '%{http_code}\n' -u "$KEY:$SECRET" \
"$FIREWALL/api/core/firmware/status"
The call should now return 401. The key/secret pair belonged
to the deleted user, so it no longer resolves to anything.
Then clear the credentials out of the shell, so they do not survive in the environment or the history:
unset KEY SECRET key secret
What you learned
- The OPNsense API authenticates with an API key/secret pair
presented as HTTP Basic credentials —
curl -u "$KEY:$SECRET", the key as username and the secret as password. There is no bearer token and no custom header, and 401 and 403 tell you two different things. - The pair belongs to a user account and carries that account’s privileges. Scoping is done by scoping the user.
- The disciplined lifecycle for any write is search, add, apply, verify. The apply step is separate and easy to forget.
- The API response is not proof that the change is live. The pf table is the proof, and the two can disagree indefinitely.
- A least-privilege service account is the production default. Root is for break-glass.
- The API key is a credential. Store it in a secrets manager. Rotate it. Revoke it on personnel changes.