Skip to content

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.

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.

MethodPathPurpose
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.

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
}
FieldTypeRequiredConstraints
namestringyes1-100 characters
descriptionstringnoup to 2,000 characters
scopesarray of stringsnoup to 32 entries; each entry up to 100 characters
expires_in_daysintegerno1-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.

{
"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
}

When you create a key with "scopes": ["device:read", "network:read"], the server enforces two checks at creation time:

  1. Each scope in the list must already be present in your effective permission set. If you do not hold network:read, the request returns 403.
  2. 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.

FreeSDN uses two permission syntaxes that coexist across the codebase:

SyntaxExamplesUsed for
Colon-separateddevice:read, network:*, vpn:writeCore resources
Dot-separatedcameras.view, firewall.manage_rules, voip.manage_phonesModule 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.

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.

Scopevieweroperatorsite_adminorg_adminadminsuper_admin
device:readyesyesyesyesyesyes
device:update-yesyesyesyesyes
device:reboot-yesyesyesyesyes
network:readyesyesyesyesyesyes
network:*--yesyesyesyes
firewall.*---yesyesyes
cameras.viewyesyesyesyesyesyes
cameras.ptz-yesyesyesyesyes
cameras.playbackyesyesyesyesyesyes
vpn:readyesyesyesyesyesyes
vpn:write--yesyesyesyes
hypervisor:*----yesyes
user:*----yesyes
audit:readyesyesyesyesyesyes
settings:read--yesyesyesyes
discovery:run-yesyesyesyesyes
agent:*---yesyesyes

This is a representative subset. For the authoritative list, see Roles and Permissions.

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.

Send the full key string in the X-API-Key header:

GET /api/v1/devices/
X-API-Key: fsd_a1b2c3d4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The 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.

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.

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.

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.

All of your API keys are revoked automatically in two situations:

  1. Password change (POST /api/v1/auth/password) - revokes all keys belonging to your account.
  2. Global logout (POST /api/v1/auth/logout-all) - bumps token_version and 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.

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”
  1. Authenticate and obtain an access token:

    POST /api/v1/auth/login
    Content-Type: application/json
    {"login": "alice@example.com", "password": "..."}
  2. 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
    }
  3. Copy the key field from the response. You will not see it again.

  4. Store it in your secrets manager, then reference it in requests:

    GET /api/v1/devices/
    X-API-Key: fsd_a1b2c3d4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  5. Verify the key is active:

    GET /api/v1/api-keys/
    Authorization: Bearer <access_token>

    Confirm the entry appears and is_active is true.

  6. When the service account is decommissioned, revoke the key:

    DELETE /api/v1/api-keys/{key_id}
    Authorization: Bearer <access_token>

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.