API Keys
API keys give long-lived, non-interactive credentials to scripts, CI pipelines, monitoring agents, and service accounts. Unlike a JWT session, an API key does not expire every 30 minutes and does not require you to manage token rotation. Unlike a full user session, a scoped API key carries only the permissions you explicitly declare - even if the creating user is super_admin.
What API keys are (and are not)
Section titled “What API keys are (and are not)”An API key is a bearer credential sent in the X-API-Key request header. The server extracts it, looks up the matching APIKey database row by prefix, SHA-256-verifies the secret (constant-time comparison via secrets.compare_digest), checks the expiry, and builds a principal marked scoped=True.
From that point on, the scoped flag is the enforcement mechanism. A scoped principal’s has_permission() call evaluates only against the key’s explicit scopes list. The normal role-level short-circuits - super_admin bypasses most permission checks - do not apply. If the scope list does not include device:read, a super_admin-owned key is refused exactly as if it held no permissions at all.
An API key is still subject to the creating user’s own permissions at creation time. You cannot issue a key with more authority than you currently hold. The wildcard * is never grantable through this endpoint.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/api-keys/ | List your active keys (secrets never returned) |
POST | /api/v1/api-keys/ | Create a key - full secret returned once |
DELETE | /api/v1/api-keys/{key_id} | Revoke and delete a key (204) |
All three endpoints require an authenticated caller. You can only manage keys that belong to your own account.
Creating a key
Section titled “Creating a key”Request
Section titled “Request”POST /api/v1/api-keys/Authorization: Bearer <access_token>Content-Type: application/json
{ "name": "ci-monitoring", "description": "Read-only key for the nightly device-status check", "scopes": ["device:read", "network:read", "cameras.view"], "expires_in_days": 90}| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | 1-100 characters |
description | string | no | up to 2,000 characters |
scopes | array of strings | no | up to 32 entries; each entry up to 100 characters |
expires_in_days | integer | no | 1-365 |
An empty or omitted scopes array creates an unscoped key that carries the full permission set of the creating user at the moment of each request. Prefer explicit scopes for service accounts.
Response
Section titled “Response”{ "id": "550e8400-e29b-41d4-a716-446655440000", "name": "ci-monitoring", "description": "Read-only key for the nightly device-status check", "key_prefix": "fsd_a1b2c3d4", "key": "fsd_a1b2c3d4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "scopes": ["device:read", "network:read", "cameras.view"], "expires_at": "2026-09-04T00:00:00Z", "is_active": true, "created_at": "2026-06-06T00:00:00Z", "last_used": null}Scope ceiling in detail
Section titled “Scope ceiling in detail”How the ceiling works
Section titled “How the ceiling works”When you create a key with "scopes": ["device:read", "network:read"], the server enforces two checks at creation time:
- Each scope in the list must already be present in your effective permission set. If you do not hold
network:read, the request returns403. - The wildcard
*is never allowed in a key’s scope list, regardless of your role.
At request time, the scoped=True flag on the principal means has_permission("device:write") returns false even if you are super_admin - only the explicit list is consulted.
Scope string syntax
Section titled “Scope string syntax”FreeSDN uses two permission syntaxes that coexist across the codebase:
| Syntax | Examples | Used for |
|---|---|---|
| Colon-separated | device:read, network:*, vpn:write | Core resources |
| Dot-separated | cameras.view, firewall.manage_rules, voip.manage_phones | Module permissions |
Wildcards work within a namespace. A scope of device:* matches device:read and device:write. A scope of cameras.* matches cameras.view and cameras.playback. You cannot cross namespaces with a wildcard.
Scope reference by role
Section titled “Scope reference by role”The table below shows what each built-in role can grant. You cannot issue a key with a scope outside the column you belong to.
| Scope | viewer | operator | site_admin | org_admin | admin | super_admin |
|---|---|---|---|---|---|---|
device:read | yes | yes | yes | yes | yes | yes |
device:update | - | yes | yes | yes | yes | yes |
device:reboot | - | yes | yes | yes | yes | yes |
network:read | yes | yes | yes | yes | yes | yes |
network:* | - | - | yes | yes | yes | yes |
firewall.* | - | - | - | yes | yes | yes |
cameras.view | yes | yes | yes | yes | yes | yes |
cameras.ptz | - | yes | yes | yes | yes | yes |
cameras.playback | yes | yes | yes | yes | yes | yes |
vpn:read | yes | yes | yes | yes | yes | yes |
vpn:write | - | - | yes | yes | yes | yes |
hypervisor:* | - | - | - | - | yes | yes |
user:* | - | - | - | - | yes | yes |
audit:read | yes | yes | yes | yes | yes | yes |
settings:read | - | - | yes | yes | yes | yes |
discovery:run | - | yes | yes | yes | yes | yes |
agent:* | - | - | - | yes | yes | yes |
This is a representative subset. For the authoritative list, see Roles and Permissions.
Active-key limits
Section titled “Active-key limits”Each user account can hold at most 50 active API keys. Creating a 51st key returns HTTP 409. The limit is enforced with a SELECT … FOR UPDATE lock on the user row so two concurrent create requests cannot both slip through.
{ "error": { "code": 409, "message": "API key limit reached (50)", "request_id": "ext-abc123" }}If you need more than 50 keys, audit your existing keys. Expired or unused keys should be revoked explicitly - they count against the limit until revoked.
Using a key in requests
Section titled “Using a key in requests”Send the full key string in the X-API-Key header:
GET /api/v1/devices/X-API-Key: fsd_a1b2c3d4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe server identifies the key by the fsd_a1b2c3d4 prefix, fetches the row, SHA-256-verifies the secret portion (constant-time comparison via secrets.compare_digest), checks expiry, and re-checks that the owning user’s account is still active. A key belonging to a disabled or deleted user is rejected even if the key itself has not been explicitly revoked.
API key requests do not require a CSRF token, provided the freesdn_access session cookie is absent. If your client sends both the X-API-Key header and the session cookie simultaneously, CSRF validation is still enforced. In practice this means: do not set the session cookie in automated clients; rely solely on the header.
Rate limiting
Section titled “Rate limiting”API key requests are subject to the same rate limits as JWT sessions: 600 requests per minute, 120 per second burst. The bucket is keyed on the authenticated user when the freesdn_access cookie is present; when only the X-API-Key header is sent (the normal case for service accounts) the bucket is keyed on the client IP address.
Listing your keys
Section titled “Listing your keys”GET /api/v1/api-keys/Authorization: Bearer <access_token>Returns up to 100 active keys. Each entry includes id, name, key_prefix, scopes, expires_at, is_active, created_at, and last_used. The last_used timestamp is updated on a best-effort basis on each authenticated use.
[ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "ci-monitoring", "key_prefix": "fsd_a1b2c3d4", "description": "Read-only key for the nightly device-status check", "scopes": ["device:read", "network:read", "cameras.view"], "expires_at": "2026-09-04T00:00:00Z", "is_active": true, "created_at": "2026-06-06T00:00:00Z", "last_used": "2026-06-06T04:12:00Z" }]The full secret is never returned in list or detail responses.
Revoking a key
Section titled “Revoking a key”DELETE /api/v1/api-keys/{key_id}Authorization: Bearer <access_token>Returns 204 No Content on success. The key row is deleted immediately. Any in-flight request carrying that key fails on the next database lookup.
You can only revoke keys you own. Attempting to revoke another user’s key returns 404, not 403, to avoid leaking whether the key exists.
Automatic revocation
Section titled “Automatic revocation”All of your API keys are revoked automatically in two situations:
- Password change (
POST /api/v1/auth/password) - revokes all keys belonging to your account. - Global logout (
POST /api/v1/auth/logout-all) - bumpstoken_versionand revokes all keys.
If you are rotating a compromised password, both of these also invalidate any outstanding API keys - you do not need to delete them manually.
Key expiry
Section titled “Key expiry”Keys created with expires_in_days are checked at auth time. An expired key returns 401 Unauthorized. Expired keys are not automatically deleted from the database; they count against the 50-key limit until you revoke them explicitly.
Step-by-step: create and use a read-only monitoring key
Section titled “Step-by-step: create and use a read-only monitoring key”-
Authenticate and obtain an access token:
POST /api/v1/auth/loginContent-Type: application/json{"login": "alice@example.com", "password": "..."} -
Create a scoped key valid for 365 days:
POST /api/v1/api-keys/Authorization: Bearer <access_token>Content-Type: application/json{"name": "prometheus-scraper","description": "Read device and analytics data for Prometheus","scopes": ["device:read", "analytics:read", "event:read"],"expires_in_days": 365} -
Copy the
keyfield from the response. You will not see it again. -
Store it in your secrets manager, then reference it in requests:
GET /api/v1/devices/X-API-Key: fsd_a1b2c3d4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -
Verify the key is active:
GET /api/v1/api-keys/Authorization: Bearer <access_token>Confirm the entry appears and
is_activeistrue. -
When the service account is decommissioned, revoke the key:
DELETE /api/v1/api-keys/{key_id}Authorization: Bearer <access_token>
Security considerations
Section titled “Security considerations”Treat keys like passwords. The key value is a long-lived credential. Store it in a secrets manager (Vault, AWS Secrets Manager, environment-injected CI secrets) rather than hardcoding it in source code or config files checked into version control.
Use the narrowest scope that works. A monitoring script needs device:read, not network:*. Narrowing the scope limits blast radius if a key is leaked.
Set an expiry. A key with expires_in_days: 90 that leaks expires without action. A key with no expiry is valid until explicitly revoked - forever if you forget about it.
Rotate periodically. Create a replacement key with the same scopes, update the consumer, then revoke the old key. The key_prefix field (visible in the list endpoint and in server logs) lets you correlate a key in logs to its id for targeted revocation.
Audit last_used. Keys that have not been used in weeks are likely orphaned. Revoke them before they accumulate.
Next steps
Section titled “Next steps”- Authentication - JWT sessions, MFA, OIDC/LDAP SSO, refresh rotation
- Using the API - worked examples, staged writes, WebSocket
- Roles and Permissions - full permission matrix by role
- Hardening Checklist - production deployment security baseline