NetStacksNetStacks

API Authentication

Authenticate with the NetStacks Controller API using JWT access and refresh tokens, local/LDAP/OIDC providers, sessions, and rate limiting.

Overview

The NetStacks Controller API uses JWT (JSON Web Tokens) for authentication. Every protected API request must include a valid access token in the Authorization header. Login, refresh, the provider list, and health checks are the only unauthenticated endpoints. NetStacks supports three authentication providers:

  • Local authentication — Username and password verified against the built-in user database (POST /api/auth/login). Returns an access token and a refresh token.
  • LDAP / Active Directory — Authenticate against your existing directory service (POST /api/auth/ldap). The Controller performs an LDAP bind and, on success, finds or auto-creates a local user record and issues JWT tokens.
  • OIDC / SSO — Single sign-on via OpenID Connect. A browser redirect flow (GET /api/auth/oidc/authorize → provider → GET /api/auth/oidc/callback) returns tokens in the URL fragment of your redirect URI.

All providers return the same TokenResponse shape. Once authenticated, API usage is identical regardless of provider.

Rate limiting on login

The /api/auth/login and /api/auth/ldap endpoints are rate-limited per client IP to slow brute-force attacks. After repeated failures the API returns 401 with an attempts-remaining hint, and once the threshold is exceeded the client is locked out and receives 429 Too Many Requests. See the Error Codes page for details.

How It Works

JWT Token Flow

When you authenticate via POST /api/auth/login, the API returns a TokenResponse containing:

  • access_token — A short-lived JWT included in the Authorization: Bearer header of every protected request. Its claims carry the user ID (sub), the organization ID (org_id), the username, and the assigned roles.
  • refresh_token — A longer-lived opaque token used to obtain a new access token without re-entering credentials. Only a SHA-256 hash is stored server-side, and it is single-use: each refresh revokes the old token and issues a new one (rotation).
  • token_type — Always "Bearer".
  • expires_in — Seconds until the access token expires.

Access-token and refresh-token lifetimes are configured by the Controller administrator. Do not hard-code an expiry — always read the expires_in value returned with each login and refresh.

Token Lifecycle

  1. Client sends credentials to /api/auth/login.
  2. Server validates credentials and creates a session. For licensed deployments a seat check runs first; a full seat pool returns 409 Conflict with a seat_limit_reached message.
  3. Server returns access_token, refresh_token, token_type, and expires_in.
  4. Client includes the access token in every request: Authorization: Bearer <access_token>.
  5. Before the access token expires, client calls POST /api/auth/refresh with the current refresh token.
  6. Server validates and revokes the old refresh token, then returns a new access/refresh token pair.
client_type and license seats

The login and LDAP requests accept an optional client_type field. "terminal" consumes a license seat; "admin_ui" (the default) does not. Browser-based admin and API automation should leave this at the default.

SSH Certificate Auto-Signing

If you include an OpenSSH public_key in the login request, the Controller signs a short-lived user SSH certificate from the organization's default CA and returns it as an ssh_certificate object on the token response (only present when a public key is supplied). This lets terminal clients authenticate to managed devices without a separate signing call.

LDAP Authentication

For LDAP/AD environments, use POST /api/auth/ldap. The Controller binds with the supplied credentials and, on success, looks up the user by their LDAP DN. First-time users are auto-created when auto-provisioning is enabled; otherwise the request is rejected as if the credentials were invalid.

OIDC / SSO Flow

OIDC uses a browser redirect flow. Call GET /api/auth/oidc/authorize?redirect_uri=... to be redirected to your identity provider. After the user authenticates, the provider returns to GET /api/auth/oidc/callback, which creates the session and redirects back to your redirect_uri with access_token, refresh_token, token_type=Bearer, and expires_in in the URL fragment.

Session Management

Each login creates a session record. View active sessions via GET /api/auth/sessions and revoke a specific one via DELETE /api/auth/sessions/:id. The DELETE /api/auth/sessions/other endpoint revokes the user's sessions and forces a re-login.

Step-by-Step Guide

1. Check Available Auth Providers

Before authenticating, discover which providers are enabled on your Controller:

providers-response.jsonjson
GET /api/auth/providers

Response:
{
  "providers": [
    { "name": "local", "enabled": true },
    { "name": "ldap", "enabled": true },
    { "name": "oidc", "enabled": false }
  ]
}

2. Authenticate to Get Tokens

Send your credentials to the login endpoint. Use /api/auth/login for local accounts or /api/auth/ldap for LDAP/AD accounts.

3. Include the Access Token in Requests

Add the Authorization header to every protected API call:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

4. Refresh Before Expiry

Track the expires_in value from the login response. Before the access token expires, call the refresh endpoint to get a new pair without re-authenticating. Because refresh tokens are single-use, always store the new refresh_token returned by each refresh.

5. Handle Rate Limiting

If you receive a 429 response, the login lockout is active. Wait before retrying and implement exponential backoff in automated scripts: wait 1 second, then 2, then 4, up to a maximum of 30 seconds.

6. Log Out When Done

Call POST /api/auth/logout to revoke the current session and all of the user's refresh tokens.

Code Examples

Login (Local Authentication)

login.shbash
# Login with username and password
curl -X POST https://netstacks.example.net/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "netops-api",
    "password": "s3cur3-passw0rd!"
  }'

# Response:
# {
#   "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
#   "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
#   "token_type": "Bearer",
#   "expires_in": 3600
# }

If you include an OpenSSH public_key in the request body, the response also contains an ssh_certificate object:

login-with-cert.shbash
# Login and request an auto-signed SSH user certificate
curl -X POST https://netstacks.example.net/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "netops-api",
    "password": "s3cur3-passw0rd!",
    "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@host"
  }'

# Response (ssh_certificate present only when public_key was supplied):
# {
#   "access_token": "eyJhbGciOiJIUzI1NiIs...",
#   "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
#   "token_type": "Bearer",
#   "expires_in": 3600,
#   "ssh_certificate": {
#     "certificate": "[email protected] AAAA...",
#     "ca_public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
#     "valid_after": "2026-06-16T12:00:00+00:00",
#     "valid_before": "2026-06-16T20:00:00+00:00",
#     "serial": 42
#   }
# }
login.pypython
import requests

base_url = "https://netstacks.example.net/api"

# Login
resp = requests.post(f"{base_url}/auth/login", json={
    "username": "netops-api",
    "password": "s3cur3-passw0rd!",
})
resp.raise_for_status()
tokens = resp.json()
print(f"token_type={tokens['token_type']}, expires_in={tokens['expires_in']}s")

# Reusable auth header
headers = {"Authorization": f"Bearer {tokens['access_token']}"}

# Make an authenticated request
devices = requests.get(f"{base_url}/devices", headers=headers)
print(devices.json())
login.tstypescript
const BASE_URL = "https://netstacks.example.net/api";

interface TokenResponse {
  access_token: string;
  refresh_token: string;
  token_type: string;
  expires_in: number;
}

async function login(username: string, password: string): Promise<TokenResponse> {
  const resp = await fetch(`${BASE_URL}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });

  if (!resp.ok) {
    throw new Error(`Login failed: ${resp.status} ${resp.statusText}`);
  }

  return resp.json();
}

// Usage
const tokens = await login("netops-api", "s3cur3-passw0rd!");
console.log(`Token expires in ${tokens.expires_in} seconds`);

// Authenticated request
const devices = await fetch(`${BASE_URL}/devices`, {
  headers: { Authorization: `Bearer ${tokens.access_token}` },
});
console.log(await devices.json());

LDAP Login

ldap-login.shbash
# Authenticate against LDAP / Active Directory
curl -X POST https://netstacks.example.net/api/auth/ldap \
  -H "Content-Type: application/json" \
  -d '{
    "username": "jdoe",
    "password": "directory-password"
  }'

# Same TokenResponse shape as /api/auth/login:
# {
#   "access_token": "eyJhbGciOiJIUzI1NiIs...",
#   "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
#   "token_type": "Bearer",
#   "expires_in": 3600
# }

Token Refresh

refresh.shbash
# Exchange the refresh token for a NEW access + refresh pair
curl -X POST https://netstacks.example.net/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }'

# Response (the old refresh_token is now revoked — store the new one):
# {
#   "access_token": "eyJhbGciOiJIUzI1NiIs...(new)",
#   "refresh_token": "eyJhbGciOiJIUzI1NiIs...(new)",
#   "token_type": "Bearer",
#   "expires_in": 3600
# }
client.pypython
import requests


def refresh_token(base_url: str, token: str) -> dict:
    """Exchange a refresh token for a new token pair."""
    resp = requests.post(f"{base_url}/auth/refresh", json={"refresh_token": token})
    resp.raise_for_status()
    return resp.json()


# Auto-refresh client wrapper
class NetStacksClient:
    def __init__(self, base_url: str, username: str, password: str):
        self.base_url = base_url
        resp = requests.post(
            f"{base_url}/auth/login",
            json={"username": username, "password": password},
        )
        resp.raise_for_status()
        self.tokens = resp.json()

    @property
    def headers(self):
        return {"Authorization": f"Bearer {self.tokens['access_token']}"}

    def refresh(self):
        # Refresh tokens are single-use: keep the new refresh_token.
        self.tokens = refresh_token(self.base_url, self.tokens["refresh_token"])

    def get(self, path: str) -> dict:
        resp = requests.get(f"{self.base_url}{path}", headers=self.headers)
        if resp.status_code == 401:
            self.refresh()
            resp = requests.get(f"{self.base_url}{path}", headers=self.headers)
        resp.raise_for_status()
        return resp.json()
client.tstypescript
async function refreshAccessToken(
  baseUrl: string,
  refreshToken: string
): Promise<TokenResponse> {
  const resp = await fetch(`${baseUrl}/auth/refresh`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: refreshToken }),
  });

  if (!resp.ok) {
    throw new Error("Token refresh failed — re-authenticate");
  }

  return resp.json();
}

// Auto-refresh wrapper. Refresh tokens rotate on every use, so the
// wrapper always stores the latest pair returned by the server.
class NetStacksClient {
  private tokens: TokenResponse;
  private refreshTimer?: ReturnType<typeof setTimeout>;

  constructor(private baseUrl: string, tokens: TokenResponse) {
    this.tokens = tokens;
    this.scheduleRefresh();
  }

  private scheduleRefresh() {
    const ms = Math.max(0, (this.tokens.expires_in - 60) * 1000);
    this.refreshTimer = setTimeout(() => this.refresh(), ms);
  }

  private async refresh() {
    this.tokens = await refreshAccessToken(this.baseUrl, this.tokens.refresh_token);
    this.scheduleRefresh();
  }

  async get<T>(path: string): Promise<T> {
    const resp = await fetch(`${this.baseUrl}${path}`, {
      headers: { Authorization: `Bearer ${this.tokens.access_token}` },
    });
    if (resp.status === 401) {
      await this.refresh();
      return this.get(path);
    }
    return resp.json();
  }
}

Get Current User Info

me.shbash
# Get info about the authenticated user
curl https://netstacks.example.net/api/auth/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

# Response (email and display_name may be null for some accounts):
# {
#   "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
#   "org_id": "11111111-2222-3333-4444-555555555555",
#   "username": "netops-api",
#   "email": "[email protected]",
#   "display_name": "Network Ops API Account",
#   "roles": ["network-admin"],
#   "permissions": ["devices.access", "devices.manage", "credentials.manage", "config.deploy"],
#   "is_active": true,
#   "auth_provider": "local",
#   "created_at": "2026-01-04T09:12:00Z",
#   "updated_at": "2026-06-01T14:30:00Z"
# }

The permissions array contains dot-keyed permission strings aggregated from the user's roles (for example devices.access, config.deploy, users.manage). The wildcard * grants every permission. The auth_provider field reflects how the account authenticates (local, ldap, or oidc).

Logout

logout.shbash
# Logout: revoke the current session and the user's refresh tokens
curl -X POST https://netstacks.example.net/api/auth/logout \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

# Response:
# {
#   "message": "Successfully logged out",
#   "sessions_revoked": 1
# }

Questions & Answers

How do I authenticate to the NetStacks API?
Send a POST request to /api/auth/login with your username and password as JSON. The response is a TokenResponse containing access_token, refresh_token, token_type ("Bearer"), and expires_in. Include the access token in the Authorization: Bearer <token> header of every subsequent request.
How long do access tokens last?
Access-token lifetime is configured by the Controller administrator. Do not assume a fixed value — read the expires_in field (in seconds) returned with each login and refresh, and refresh shortly before it elapses.
How do I refresh an expired token?
Send a POST request to /api/auth/refresh with your refresh_token in the JSON body. The response is a new token pair. Refresh tokens are single-use: the old one is revoked on every refresh, so always store the new refresh_token from the response. Refresh-token lifetime is set by the administrator.
What authentication providers does NetStacks support?
Three: local username/password, LDAP/Active Directory, and OIDC/SSO (OpenID Connect). Call GET /api/auth/providers to see which are enabled on your Controller.
What permissions does the API use?
Roles grant dot-keyed permission strings such as devices.access, devices.manage, config.deploy, credentials.manage, users.manage, and system.manage. The wildcard * grants everything. Call GET /api/auth/me to see the permissions resolved for the current user.
What happens when I hit the rate limit?
Login endpoints are rate-limited per client IP. After repeated failures you receive 401 with an attempts-remaining hint; once the threshold is crossed the IP is locked out and receives 429 Too Many Requests. Wait, then retry with exponential backoff.
How do I revoke a token or end a session?
Call POST /api/auth/logout to revoke the current session and the user's refresh tokens. To revoke a specific session, use DELETE /api/auth/sessions/:id. To revoke the user's other sessions, call DELETE /api/auth/sessions/other (you will need to re-login afterward).
Can I change my password via the API?
Yes, for local accounts only. Send a PUT request to /api/auth/password with current_password and new_password in the body. The new password must satisfy the password policy. On success, all refresh tokens are revoked and every device must re-login. LDAP and OIDC accounts cannot change their password here.

Troubleshooting

401 Unauthorized

The access token is missing, expired, or invalid. Confirm you are sending the Authorization: Bearer <token> header and that the token has not expired. Call POST /api/auth/refresh to obtain a new access token.

403 Forbidden

The token is valid but the user lacks the dot-keyed permission key required for the operation (for example devices.manage or config.deploy). Call GET /api/auth/me to see the user's resolved roles and permissions, then adjust their role assignments in Roles & Permissions.

409 Seat Limit Reached

Licensed deployments check seat availability at login. A response of 409 with a seat_limit_reached message means the seat pool is full. Free a seat by logging out an existing session (POST /api/auth/logout or DELETE /api/auth/sessions/:id), or set client_type to the default admin_ui, which does not consume a seat.

429 Too Many Requests

You have exceeded the login rate limit. This is an IP-based lockout to slow brute-force attacks. Wait before retrying and have automated scripts back off exponentially.

CORS Errors in Browser Applications

If you are calling the API from a browser-based application, ensure your origin is included in the Controller's allowed CORS origins. Check the Controller's CORS configuration in the admin settings.

LDAP Authentication Failing

Verify the LDAP configuration in the admin settings. An admin can call POST /api/auth/ldap/test (requires the admin role) to test connectivity. Common issues are an incorrect bind DN, wrong base DN, or no network path to the LDAP server. Note that LDAP errors are intentionally returned as generic 401 Invalid credentials to avoid leaking account information.

  • User Management — Create and manage user accounts and configure authentication providers.
  • Roles & Permissions — Define roles and assign the dot-keyed permission keys that control API access.
  • Devices API — Manage network devices programmatically after authenticating.
  • Templates API — Create and render configuration templates via the API.
  • Error Codes — Complete reference for API error responses, including authentication and rate-limit errors.