Skip to content

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.

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.

MethodPathPurposeAuth
POST/api/v1/auth/loginJSON login - returns tokens or MFA challengepublic
POST/api/v1/auth/tokenOAuth2 password-grant form loginpublic
POST/api/v1/auth/login/mfaComplete a TOTP or backup-code challengepublic, rate-limited
POST/api/v1/auth/refreshRotate refresh → new access + refresh pairvalid refresh token
GET/api/v1/auth/meCurrent user info including enriched permissionsauthenticated
PATCH/api/v1/auth/meUpdate profile (full_name, username, language)authenticated
POST/api/v1/auth/passwordChange own password (revokes other sessions + API keys)authenticated
POST/api/v1/auth/logoutPer-device logout - blacklists this session’s JTIauthenticated
POST/api/v1/auth/logout-allGlobal logout - bumps token_version, revokes all sessions + API keysauthenticated
GET/api/v1/auth/sessionsList active sessions (metadata only, no secrets)authenticated
DELETE/api/v1/auth/sessions/{session_id}Revoke a specific session by IDauthenticated, owner
POST/api/v1/auth/mfa/setupBegin TOTP enrolment - returns provisioning URI + 10 backup codesauthenticated
POST/api/v1/auth/mfa/enableConfirm enrolment with a valid TOTP codeauthenticated
POST/api/v1/auth/mfa/disableDisable MFA (requires current password)authenticated
POST/api/v1/auth/registerSelf-register (viewer role) - gated by ALLOW_REGISTRATIONpublic
POST/api/v1/auth/password/reset-requestEmail reset link (always returns 200 - anti-enumeration)public
POST/api/v1/auth/password/resetConsume reset token + set new passwordpublic

The login endpoint accepts either an email address or a username in the login field.

POST /api/v1/auth/login
Content-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.

The /auth/token endpoint follows the OAuth2 password-grant spec and accepts application/x-www-form-urlencoded:

POST /api/v1/auth/token
Content-Type: application/x-www-form-urlencoded
username=alice%40example.com&password=correct-horse-battery-staple

When /auth/login returns require_mfa: true, supply the mfa_token and a 6-digit TOTP code:

POST /api/v1/auth/login/mfa
Content-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.

You must be authenticated to enrol MFA. The setup is a two-step commit:

  1. Begin enrolment - stages a pending TOTP secret:

    POST /api/v1/auth/mfa/setup
    Authorization: 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.

  2. Confirm enrolment - promotes the pending secret to live by verifying one TOTP code:

    POST /api/v1/auth/mfa/enable
    Authorization: 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.

POST /api/v1/auth/mfa/disable
Authorization: Bearer <access_token>
Content-Type: application/json
{
"password": "<current password>"
}

This also bumps token_version and revokes all sessions.

Access tokens are signed HS256 JWTs carrying:

ClaimContent
subUser UUID
org_idOrganization UUID
roleRole name (display hint - see caution below)
jtiUnique token ID (used for per-device revocation)
tvtoken_version integer (used for global revocation)
audfreesdn-api
issfreesdn
Token typeDefault lifetimeConfiguration variable
Access30 minutesACCESS_TOKEN_EXPIRE_MINUTES
Refresh7 daysREFRESH_TOKEN_EXPIRE_DAYS

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

When the access token expires, exchange the refresh token for a new pair:

POST /api/v1/auth/refresh
Content-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 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.

FreeSDN has two independent revocation mechanisms that work at different scopes:

MechanismScopeHow it works
JTI blacklistSingle sessionThe access or refresh token’s jti is added to a Valkey blacklist. Other sessions are unaffected.
token_version bumpAll sessionsThe integer on the user row is incremented. Every outstanding token carries the old tv value and is rejected. Cleared on next login.

Revokes only the calling session. Other devices remain logged in:

POST /api/v1/auth/logout
Authorization: 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.

Bumps token_version, invalidating every access and refresh token across all devices simultaneously. Also revokes all API keys:

POST /api/v1/auth/logout-all
Authorization: Bearer <access_token>

Use this after a credential compromise or when handing off a device.

List all active sessions (device metadata only - no token secrets exposed):

GET /api/v1/auth/sessions
Authorization: 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.

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

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.

FieldRequiredConstraints
nameyes1-100 characters
descriptionno≤ 2,000 characters
scopesno≤ 32 entries; each entry ≤ 100 characters
expires_in_daysno1-365

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.

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.

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 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-corp
MethodPathPurposeAuth
GET/api/v1/auth/sso/providers/publicProvider buttons for login page (requires organization_slug)public
POST/api/v1/auth/sso/oidc/authorizeStart OIDC authorization-code flowpublic
POST/api/v1/auth/sso/oidc/callbackExchange code for FreeSDN tokenspublic
POST/api/v1/auth/sso/ldap/authenticateLDAP credential bindpublic
GET/api/v1/auth/sso/providersList configured providerssuper_admin or org_admin
GET/api/v1/auth/sso/providers/{id}Get a providersuper_admin or org_admin
POST/api/v1/auth/sso/providersCreate a providersuper_admin or org_admin
PATCH/api/v1/auth/sso/providers/{id}Update a providersuper_admin or org_admin
DELETE/api/v1/auth/sso/providers/{id}Soft-delete a providersuper_admin or org_admin
POST/api/v1/auth/sso/providers/{id}/testTest IdP connectivitysuper_admin or org_admin
  1. Initiate the flow - get the IdP redirect URL:

    POST /api/v1/auth/sso/oidc/authorize
    Content-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.

  2. Exchange the code - after the IdP redirects back to your redirect_uri:

    POST /api/v1/auth/sso/oidc/callback
    Content-Type: application/json
    {
    "code": "<authorization_code>",
    "state": "<state_from_authorize_response>"
    }

    The state value is the opaque nonce returned by /auth/sso/oidc/authorize; the server resolves the provider from it server-side. Do not send provider_slug - it is not a field on this endpoint and will be silently ignored by Pydantic (or cause a 422 if sent instead of state).

    A successful callback returns the same access_token / refresh_token shape as a password login and sets the session cookies for browser clients.

POST /api/v1/auth/sso/ldap/authenticate
Content-Type: application/json
{
"provider_slug": "acme-ldap",
"username": "alice",
"password": "ldap-password"
}

Returns the same token shape on success.

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.

This section summarizes the specific mechanisms in place. It is accurate to the current codebase; no compliance certifications (SOC 2, etc.) are claimed.

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.

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.

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.

ScopeLimitFailure mode
Per IP on /auth/*5 requests / minuteFail-closed - returns 503 when Valkey is unreachable
Per username (sliding window)20 failed attempts / 5 minutesIn-memory fallback (avoids remote lockout)
Successful loginResets 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.

Two complementary mechanisms work together:

  1. JTI blacklist (Valkey) - the jti claim of a revoked token is added to a sorted-set blacklist. The auth dependency rejects any token whose jti is present, regardless of signature validity. Used for per-device logout.

  2. token_version (database) - an integer on the user row. Every token carries the version at issuance as the tv claim. The auth dependency rejects any token whose tv does not match the current user.token_version. Bumping the version (password change, logout-all, MFA toggle, admin role change) instantly invalidates all outstanding tokens with no blacklist scan.

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.

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.

Terminal window
# 1. Log in - capture both the access token and the refresh token
LOGIN_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 endpoint
curl -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)
Terminal window
# 1. Initial login - returns mfa_token, no session yet
MFA_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 .
fi
Terminal window
# 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 required
curl -s https://freesdn.example.com/api/v1/devices/ \
-H "X-API-Key: $KEY" | jq .items[].hostname

Revoking all sessions after a credential event

Section titled “Revoking all sessions after a credential event”
Terminal window
# Bumps token_version - every outstanding token is immediately invalid
curl -s -X POST https://freesdn.example.com/api/v1/auth/logout-all \
-H "Authorization: Bearer $TOKEN"