Authentication
FreeSDN supports three credential types: JWT Bearer tokens for interactive sessions, httpOnly cookies for browser clients, and scoped API keys for automation. This page covers every auth flow: login, TOTP MFA, token refresh and rotation, revocation, SSO via OIDC and LDAP, and API key lifecycle. Understanding how each piece fits together will save you from the most common integration pitfalls.
Auth endpoint reference
Section titled “Auth endpoint reference”The full authentication surface lives under /api/v1/auth and /api/v1/auth/sso. All public endpoints are CSRF-exempt and rate-limited. Authenticated endpoints require a valid Bearer token or session cookie.
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST | /api/v1/auth/login | JSON login - returns tokens or MFA challenge | public |
POST | /api/v1/auth/token | OAuth2 password-grant form login | public |
POST | /api/v1/auth/login/mfa | Complete a TOTP or backup-code challenge | public, rate-limited |
POST | /api/v1/auth/refresh | Rotate refresh → new access + refresh pair | valid refresh token |
GET | /api/v1/auth/me | Current user info including enriched permissions | authenticated |
PATCH | /api/v1/auth/me | Update profile (full_name, username, language) | authenticated |
POST | /api/v1/auth/password | Change own password (revokes other sessions + API keys) | authenticated |
POST | /api/v1/auth/logout | Per-device logout - blacklists this session’s JTI | authenticated |
POST | /api/v1/auth/logout-all | Global logout - bumps token_version, revokes all sessions + API keys | authenticated |
GET | /api/v1/auth/sessions | List active sessions (metadata only, no secrets) | authenticated |
DELETE | /api/v1/auth/sessions/{session_id} | Revoke a specific session by ID | authenticated, owner |
POST | /api/v1/auth/mfa/setup | Begin TOTP enrolment - returns provisioning URI + 10 backup codes | authenticated |
POST | /api/v1/auth/mfa/enable | Confirm enrolment with a valid TOTP code | authenticated |
POST | /api/v1/auth/mfa/disable | Disable MFA (requires current password) | authenticated |
POST | /api/v1/auth/register | Self-register (viewer role) - gated by ALLOW_REGISTRATION | public |
POST | /api/v1/auth/password/reset-request | Email reset link (always returns 200 - anti-enumeration) | public |
POST | /api/v1/auth/password/reset | Consume reset token + set new password | public |
Password login
Section titled “Password login”The login endpoint accepts either an email address or a username in the login field.
Step 1 - POST to /auth/login
Section titled “Step 1 - POST to /auth/login”POST /api/v1/auth/loginContent-Type: application/json
{ "login": "alice@example.com", "password": "correct-horse-battery-staple"}Success (no MFA):
{ "access_token": "<JWT>", "refresh_token": "<JWT>", "token_type": "bearer", "expires_in": 1800}For browser clients the server also sets three httpOnly cookies: freesdn_access (path /api/v1/), freesdn_refresh (path /api/v1/auth/refresh), and freesdn_csrf (path /, not httpOnly so the SPA can read it via document.cookie). The Secure flag is only set in production and staging environments.
MFA-required response:
{ "require_mfa": true, "mfa_token": "<short-lived MFA-pending JWT>"}When this response arrives, no access or refresh token is issued yet. You must complete the MFA step before a session is created.
OAuth2 form login
Section titled “OAuth2 form login”The /auth/token endpoint follows the OAuth2 password-grant spec and accepts application/x-www-form-urlencoded:
POST /api/v1/auth/tokenContent-Type: application/x-www-form-urlencoded
username=alice%40example.com&password=correct-horse-battery-stapleTOTP MFA
Section titled “TOTP MFA”Completing the challenge
Section titled “Completing the challenge”When /auth/login returns require_mfa: true, supply the mfa_token and a 6-digit TOTP code:
POST /api/v1/auth/login/mfaContent-Type: application/json
{ "mfa_token": "<token from /auth/login>", "code": "123456"}A successful response returns the same access_token / refresh_token shape as a direct login.
You can also supply an 8-character backup code in place of the TOTP code. Backup codes are single-use - they are popped from the list on consumption. TOTP codes are single-use per 30-second time step (replay resistance).
The mfa_token carries a 5-minute expiry and is bound to the user’s current token_version. If the user changes their password or calls /auth/logout-all mid-flow, the MFA token is immediately invalidated.
Setting up TOTP
Section titled “Setting up TOTP”You must be authenticated to enrol MFA. The setup is a two-step commit:
-
Begin enrolment - stages a pending TOTP secret:
POST /api/v1/auth/mfa/setupAuthorization: Bearer <access_token>If re-enrolling (MFA already active), include the current password in the request body. Response includes a
provisioning_uri(compatible with Google Authenticator, Authy, etc.) and 10 backup codes. Store the backup codes securely now - they are not shown again. -
Confirm enrolment - promotes the pending secret to live by verifying one TOTP code:
POST /api/v1/auth/mfa/enableAuthorization: Bearer <access_token>Content-Type: application/json{"code": "654321"}A successful enable bumps the user’s
token_version, invalidating all existing sessions. You will need to log in again.
Disabling TOTP
Section titled “Disabling TOTP”POST /api/v1/auth/mfa/disableAuthorization: Bearer <access_token>Content-Type: application/json
{ "password": "<current password>"}This also bumps token_version and revokes all sessions.
JWT access and refresh tokens
Section titled “JWT access and refresh tokens”Token structure
Section titled “Token structure”Access tokens are signed HS256 JWTs carrying:
| Claim | Content |
|---|---|
sub | User UUID |
org_id | Organization UUID |
role | Role name (display hint - see caution below) |
jti | Unique token ID (used for per-device revocation) |
tv | token_version integer (used for global revocation) |
aud | freesdn-api |
iss | freesdn |
| Token type | Default lifetime | Configuration variable |
|---|---|---|
| Access | 30 minutes | ACCESS_TOKEN_EXPIRE_MINUTES |
| Refresh | 7 days | REFRESH_TOKEN_EXPIRE_DAYS |
Using an access token
Section titled “Using an access token”Send the access token in the Authorization header:
GET /api/v1/devices/Authorization: Bearer <access_token>The server also accepts the token from the freesdn_access httpOnly cookie for browser sessions. If both header and cookie are present simultaneously, CSRF validation is enforced (see API Overview).
Refreshing tokens
Section titled “Refreshing tokens”When the access token expires, exchange the refresh token for a new pair:
POST /api/v1/auth/refreshContent-Type: application/json
{ "refresh_token": "<refresh_token>"}For browser clients with the freesdn_refresh cookie set, the cookie is sufficient and no request body is needed.
Response:
{ "access_token": "<new_access_token>", "refresh_token": "<new_refresh_token>", "token_type": "bearer", "expires_in": 1800}Refresh rotation and replay protection
Section titled “Refresh rotation and replay protection”Refresh tokens are strictly single-use. The server claims each refresh token’s jti in Valkey using an atomic SET NX (set-if-not-exists) operation. If two requests race to use the same refresh token, exactly one wins and the other receives HTTP 401. Discard a refresh token as soon as you have received the new pair.
Logout and revocation
Section titled “Logout and revocation”FreeSDN has two independent revocation mechanisms that work at different scopes:
| Mechanism | Scope | How it works |
|---|---|---|
| JTI blacklist | Single session | The access or refresh token’s jti is added to a Valkey blacklist. Other sessions are unaffected. |
token_version bump | All sessions | The integer on the user row is incremented. Every outstanding token carries the old tv value and is rejected. Cleared on next login. |
Per-device logout
Section titled “Per-device logout”Revokes only the calling session. Other devices remain logged in:
POST /api/v1/auth/logoutAuthorization: Bearer <access_token>Both the access and refresh JTIs for the current session are blacklisted. The server also flips the revoked_at flag on the session row.
Global logout
Section titled “Global logout”Bumps token_version, invalidating every access and refresh token across all devices simultaneously. Also revokes all API keys:
POST /api/v1/auth/logout-allAuthorization: Bearer <access_token>Use this after a credential compromise or when handing off a device.
Session management
Section titled “Session management”List all active sessions (device metadata only - no token secrets exposed):
GET /api/v1/auth/sessionsAuthorization: Bearer <access_token>Revoke a specific session without affecting others:
DELETE /api/v1/auth/sessions/{session_id}Authorization: Bearer <access_token>Returns 404 if the session does not belong to the calling user.
Automatic revocation
Section titled “Automatic revocation”The following events bump token_version and revoke all API keys without an explicit logout call:
- Password change (
POST /auth/password) - Password reset (consuming a reset token)
- Global logout (
POST /auth/logout-all)
The following events bump token_version only - existing JWT sessions are invalidated immediately, but API key rows remain active:
- MFA enable (
POST /auth/mfa/enable) - MFA disable (
POST /auth/mfa/disable) - Admin disabling or changing the role of a user account
API keys that belong to a disabled or deleted user will still fail at runtime because the auth dependency re-checks user.is_active and user.deleted_at on every request.
API keys
Section titled “API keys”API keys are long-lived, scoped credentials for CI pipelines, scripts, and service accounts. They do not require a session and do not expire by MFA events.
Creating a key
Section titled “Creating a key”POST /api/v1/api-keys/Authorization: Bearer <access_token>Content-Type: application/json
{ "name": "monitoring-script", "description": "Read-only access for the observability pipeline", "scopes": ["device:read", "network:read", "event:read"], "expires_in_days": 90}Response (the key field is only returned once):
{ "id": "550e8400-e29b-41d4-a716-446655440000", "name": "monitoring-script", "key_prefix": "fk_AbC1", "key": "fk_AbC1_<full-secret>", "scopes": ["device:read", "network:read", "event:read"], "expires_at": "2026-09-03T00:00:00Z", "is_active": true, "created_at": "2026-06-06T00:00:00Z"}Store the full key immediately. It cannot be retrieved again - only the prefix is stored server-side.
Key parameters
Section titled “Key parameters”| Field | Required | Constraints |
|---|---|---|
name | yes | 1-100 characters |
description | no | ≤ 2,000 characters |
scopes | no | ≤ 32 entries; each entry ≤ 100 characters |
expires_in_days | no | 1-365 |
Scope ceiling
Section titled “Scope ceiling”An API key’s scopes list is a hard permission ceiling. The key can never grant more than the creating user currently holds. Even if the key is owned by a super_admin, it is restricted to exactly the declared scopes - no implicit expansions, no wildcard promotion.
If you omit scopes, the key inherits the creating user’s full permissions at the moment each request is evaluated. For automation, always declare explicit scopes.
Using a key
Section titled “Using a key”Send the key in the X-API-Key header:
GET /api/v1/devices/X-API-Key: fk_AbC1_<full-secret>API keys do not require a CSRF token. If both an X-API-Key header and a freesdn_access cookie are present simultaneously, CSRF validation is still enforced.
Listing and revoking keys
Section titled “Listing and revoking keys”GET /api/v1/api-keys/DELETE /api/v1/api-keys/{key_id}GET is capped at 100 results. A user may hold at most 50 active keys. Attempting to create a 51st returns HTTP 409. The server uses a SELECT ... FOR UPDATE lock on the user row to prevent concurrent creation from racing past the limit.
All of a user’s API keys are automatically revoked when:
- The user calls
/auth/logout-all - The user changes their password
When an admin disables or deletes a user account, API key rows are not explicitly revoked - but all requests using those keys will be rejected at runtime because the auth dependency re-checks user.is_active and user.deleted_at on every call. Only token_version is bumped, matching the behaviour described in the Automatic revocation section above.
SSO - OIDC and LDAP
Section titled “SSO - OIDC and LDAP”SSO providers are configured per-organization by a super_admin or org_admin. The public login-page endpoint returns only the providers belonging to the requested organization - passing no organization_slug returns an empty list, preventing cross-tenant provider enumeration.
GET /api/v1/auth/sso/providers/public?organization_slug=acme-corpSSO endpoint reference
Section titled “SSO endpoint reference”| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /api/v1/auth/sso/providers/public | Provider buttons for login page (requires organization_slug) | public |
POST | /api/v1/auth/sso/oidc/authorize | Start OIDC authorization-code flow | public |
POST | /api/v1/auth/sso/oidc/callback | Exchange code for FreeSDN tokens | public |
POST | /api/v1/auth/sso/ldap/authenticate | LDAP credential bind | public |
GET | /api/v1/auth/sso/providers | List configured providers | super_admin or org_admin |
GET | /api/v1/auth/sso/providers/{id} | Get a provider | super_admin or org_admin |
POST | /api/v1/auth/sso/providers | Create a provider | super_admin or org_admin |
PATCH | /api/v1/auth/sso/providers/{id} | Update a provider | super_admin or org_admin |
DELETE | /api/v1/auth/sso/providers/{id} | Soft-delete a provider | super_admin or org_admin |
POST | /api/v1/auth/sso/providers/{id}/test | Test IdP connectivity | super_admin or org_admin |
OIDC authorization-code flow
Section titled “OIDC authorization-code flow”-
Initiate the flow - get the IdP redirect URL:
POST /api/v1/auth/sso/oidc/authorizeContent-Type: application/json{"provider_slug": "acme-okta","redirect_uri": "https://app.example.com/auth/sso/callback"}The response includes an
authorize_url. Redirect the user there. -
Exchange the code - after the IdP redirects back to your
redirect_uri:POST /api/v1/auth/sso/oidc/callbackContent-Type: application/json{"code": "<authorization_code>","state": "<state_from_authorize_response>"}The
statevalue is the opaque nonce returned by/auth/sso/oidc/authorize; the server resolves the provider from it server-side. Do not sendprovider_slug- it is not a field on this endpoint and will be silently ignored by Pydantic (or cause a 422 if sent instead ofstate).A successful callback returns the same
access_token/refresh_tokenshape as a password login and sets the session cookies for browser clients.
LDAP credential bind
Section titled “LDAP credential bind”POST /api/v1/auth/sso/ldap/authenticateContent-Type: application/json
{ "provider_slug": "acme-ldap", "username": "alice", "password": "ldap-password"}Returns the same token shape on success.
SSO + local MFA
Section titled “SSO + local MFA”If a user who authenticates via SSO also has local TOTP MFA enabled, the SSO callback does not issue tokens. Instead it returns require_mfa: true. The client must then complete the TOTP challenge via /auth/login/mfa with the returned mfa_token before a session is granted.
Security posture of the auth layer
Section titled “Security posture of the auth layer”This section summarizes the specific mechanisms in place. It is accurate to the current codebase; no compliance certifications (SOC 2, etc.) are claimed.
Password hashing
Section titled “Password hashing”All passwords are hashed with Argon2id (64 MB memory cost / time cost 3 / parallelism 4). The server enforces a minimum password length of 12 characters and requires at least one uppercase letter, one lowercase letter, one digit, and one special character.
Timing-attack resistance
Section titled “Timing-attack resistance”Every login code path - including the no-user-found and locked-account branches - runs a full Argon2id verify against a dummy hash before returning. All branches take the same amount of time, preventing user-enumeration via response timing.
Account lockout
Section titled “Account lockout”After 5 consecutive failed login attempts, the account is locked for 30 minutes. The server returns HTTP 423 (distinct from the HTTP 401 returned for wrong credentials or an inactive account). Timing is normalized - all branches run a full Argon2id dummy verify - so the response time gives no enumeration signal, but the response status does distinguish a locked account from bad credentials.
Rate limiting
Section titled “Rate limiting”| Scope | Limit | Failure mode |
|---|---|---|
Per IP on /auth/* | 5 requests / minute | Fail-closed - returns 503 when Valkey is unreachable |
| Per username (sliding window) | 20 failed attempts / 5 minutes | In-memory fallback (avoids remote lockout) |
| Successful login | Resets the per-username counter | - |
Auth endpoints fail closed on Valkey outage at the IP level to prevent a cache-DoS from removing credential-stuffing guards. The per-username fallback uses in-process state to avoid making remote lockout trivially exploitable.
Token revocation
Section titled “Token revocation”Two complementary mechanisms work together:
-
JTI blacklist (Valkey) - the
jticlaim of a revoked token is added to a sorted-set blacklist. The auth dependency rejects any token whosejtiis present, regardless of signature validity. Used for per-device logout. -
token_version(database) - an integer on the user row. Every token carries the version at issuance as thetvclaim. The auth dependency rejects any token whosetvdoes not match the currentuser.token_version. Bumping the version (password change, logout-all, MFA toggle, admin role change) instantly invalidates all outstanding tokens with no blacklist scan.
Registration
Section titled “Registration”Self-registration is disabled by default (ALLOW_REGISTRATION=false). When enabled, new accounts receive the viewer role. The registration endpoint returns the same generic message for both new and duplicate emails to prevent enumeration.
Password reset anti-enumeration
Section titled “Password reset anti-enumeration”POST /auth/password/reset-request always returns HTTP 200, regardless of whether the email exists. Both branches impose a 0.5-second minimum latency to prevent timing-based enumeration of registered emails. In DEBUG mode without a configured email provider, the reset URL is returned directly in the response body for development convenience.
See API Overview - CSRF protection for the full double-submit cookie mechanism. The login, password reset, and SSO callback endpoints are CSRF-exempt. After login, browser clients read the freesdn_csrf cookie and include its value in the X-CSRF-Token header on all state-mutating requests.
Worked examples
Section titled “Worked examples”Full login flow without MFA
Section titled “Full login flow without MFA”# 1. Log in - capture both the access token and the refresh tokenLOGIN_RESP=$(curl -s -X POST https://freesdn.example.com/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"login":"alice@example.com","password":"hunter2"}')TOKEN=$(echo $LOGIN_RESP | jq -r .access_token)REFRESH=$(echo $LOGIN_RESP | jq -r .refresh_token)
# 2. Call an authenticated endpointcurl -s https://freesdn.example.com/api/v1/auth/me \ -H "Authorization: Bearer $TOKEN" | jq .
# 3. Refresh before expiry (use the refresh_token from step 1, not credentials)NEW_TOKENS=$(curl -s -X POST https://freesdn.example.com/api/v1/auth/refresh \ -H 'Content-Type: application/json' \ -d "{\"refresh_token\":\"$REFRESH\"}")TOKEN=$(echo $NEW_TOKENS | jq -r .access_token)REFRESH=$(echo $NEW_TOKENS | jq -r .refresh_token)Full login flow with TOTP MFA
Section titled “Full login flow with TOTP MFA”# 1. Initial login - returns mfa_token, no session yetMFA_RESP=$(curl -s -X POST https://freesdn.example.com/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"login":"alice@example.com","password":"hunter2"}')
MFA_TOKEN=$(echo $MFA_RESP | jq -r .mfa_token)REQUIRE_MFA=$(echo $MFA_RESP | jq -r .require_mfa)
if [ "$REQUIRE_MFA" = "true" ]; then # 2. Read TOTP from authenticator app or generate programmatically TOTP_CODE=$(python3 -c "import pyotp; print(pyotp.TOTP('YOUR_SECRET').now())")
# 3. Complete MFA - returns access_token + refresh_token curl -s -X POST https://freesdn.example.com/api/v1/auth/login/mfa \ -H 'Content-Type: application/json' \ -d "{\"mfa_token\":\"$MFA_TOKEN\",\"code\":\"$TOTP_CODE\"}" | jq .fiUsing a scoped API key
Section titled “Using a scoped API key”# Create a read-only key (requires an active session)KEY=$(curl -s -X POST https://freesdn.example.com/api/v1/api-keys/ \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "ci-monitor", "scopes": ["device:read","event:read"], "expires_in_days": 30 }' | jq -r .key)
# Use the key - no CSRF or session cookie requiredcurl -s https://freesdn.example.com/api/v1/devices/ \ -H "X-API-Key: $KEY" | jq .items[].hostnameRevoking all sessions after a credential event
Section titled “Revoking all sessions after a credential event”# Bumps token_version - every outstanding token is immediately invalidcurl -s -X POST https://freesdn.example.com/api/v1/auth/logout-all \ -H "Authorization: Bearer $TOKEN"Next steps
Section titled “Next steps”- API Overview - base URL, CSRF, rate limits, error shapes, and staged writes
- Using the API - worked examples for staged writes, pagination, and WebSocket
- Roles and Permissions - full permission matrix by role
- Secrets and Credentials - storing adapter credentials, Fernet encryption
- Configuration Reference -
SECRET_KEY, token lifetimes,ALLOW_REGISTRATION, and all auth-related variables