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.
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: Bearerheader 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
- Client sends credentials to
/api/auth/login. - Server validates credentials and creates a session. For licensed deployments a seat check runs first; a full seat pool returns
409 Conflictwith aseat_limit_reachedmessage. - Server returns
access_token,refresh_token,token_type, andexpires_in. - Client includes the access token in every request:
Authorization: Bearer <access_token>. - Before the access token expires, client calls
POST /api/auth/refreshwith the current refresh token. - Server validates and revokes the old refresh token, then returns a new access/refresh token pair.
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:
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 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 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
# }
# }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())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
# 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
# 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
# }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()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
# 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: 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
POSTrequest to/api/auth/loginwith your username and password as JSON. The response is aTokenResponsecontainingaccess_token,refresh_token,token_type("Bearer"), andexpires_in. Include the access token in theAuthorization: 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_infield (in seconds) returned with each login and refresh, and refresh shortly before it elapses. - How do I refresh an expired token?
- Send a
POSTrequest to/api/auth/refreshwith yourrefresh_tokenin 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 newrefresh_tokenfrom 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/providersto 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, andsystem.manage. The wildcard*grants everything. CallGET /api/auth/meto 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
401with an attempts-remaining hint; once the threshold is crossed the IP is locked out and receives429 Too Many Requests. Wait, then retry with exponential backoff. - How do I revoke a token or end a session?
- Call
POST /api/auth/logoutto revoke the current session and the user's refresh tokens. To revoke a specific session, useDELETE /api/auth/sessions/:id. To revoke the user's other sessions, callDELETE /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
PUTrequest to/api/auth/passwordwithcurrent_passwordandnew_passwordin 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.
Related Features
- 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.