API Authentication
EnterpriseLog in to the NetStacks Controller API, send the bearer token, refresh it, and manage sessions. Covers local, LDAP, and OIDC login endpoints.
Overview
The Controller API uses JSON Web Tokens (JWTs). You log in with a username and password and get two tokens back:
- An access token. Send it on every request in the
Authorization: Bearerheader. It lasts 24 hours by default (thesession.access_token_hourssetting). - A refresh token. Exchange it for a new pair of tokens when the access token runs out. It lasts 30 days by default (the
session.refresh_token_dayssetting).
There are no personal API keys or long-lived tokens. For automation, create a dedicated local user (for example automation), give it a role with only the permissions the script needs, and have the script log in and refresh.
Log in
POST /api/auth/login takes a local NetStacks username and password:
curl -s --cacert netstacks-ca.pem -X POST https://controller.example.com:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"automation","password":"<password>"}'| Field | Type | Notes |
|---|---|---|
username | string | Required. |
password | string | Required. |
client_type | string | Optional. admin_ui (the default) or terminal. A terminal login uses a license seat. Leave this out for scripts. |
public_key | string | Optional OpenSSH public key. If sent and the Controller has an SSH CA, the response also contains a signed SSH certificate (ssh_certificate). |
A successful login returns 200:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "Xk3...64 characters...",
"token_type": "Bearer",
"expires_in": 86400
}expires_in is the access token lifetime in seconds. Wrong credentials return 401 with {"error":"Invalid credentials"}. The message can also say how many attempts are left before the login lockout.
Send the token
Put the access token in the Authorization header of every /api request:
curl -s --cacert netstacks-ca.pem https://controller.example.com:3000/api/auth/me \
-H "Authorization: Bearer $TOKEN"GET /api/auth/me is a good first call. It returns who you are and what you can do:
{
"id": "3f6c...",
"org_id": "0000...0001",
"home_org_id": "0000...0001",
"is_platform_admin": false,
"username": "automation",
"email": "[email protected]",
"display_name": "Automation",
"roles": ["operator"],
"permissions": ["devices.access", "..."],
"is_active": true,
"auth_provider": "local",
"created_at": "2026-09-01T10:00:00Z",
"updated_at": "2026-09-01T10:00:00Z"
}Without a token you get 401 {"error":"Authentication required"}. With an expired or bad token you get 401 {"error":"Invalid or expired token"}.
Refresh the token
When the access token expires, exchange the refresh token for a new pair. No Authorization header is needed:
curl -s --cacert netstacks-ca.pem -X POST https://controller.example.com:3000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"'"$REFRESH"'"}'The response has the same shape as the login response. Refresh tokens are single use: each refresh revokes the old refresh token and returns a new one, so always save the new refresh_token. A refresh also puts a platform administrator back in their home organization.
Log out
POST /api/auth/logout (with the bearer token) ends your session. It revokes all of your refresh tokens, on every device, and frees the license seat if the session used one. It is recorded in the audit log as auth.logout.
{ "message": "Successfully logged out", "sessions_revoked": 2 }To end only one session, use DELETE /api/auth/sessions/:id. To end every session except the current one, use DELETE /api/auth/sessions/other.
Endpoint reference
All paths are under /api/auth.
| Method | Path | Purpose | Permission |
|---|---|---|---|
POST | /login | Log in with a local username and password | None |
POST | /ldap | Log in with LDAP / Active Directory credentials | None |
POST | /refresh | Exchange a refresh token for a new token pair. Body: {"refresh_token"} | None |
GET | /providers | Which login providers are configured: {"providers":[{"name":"local","enabled":true},…]} | None |
GET | /oidc/authorize | Start an OpenID Connect browser login | None |
GET | /oidc/callback | OIDC redirect target (called by your identity provider) | None |
GET | /me | Current user, roles, and computed permissions | Signed in |
POST | /logout | Log out and revoke your refresh tokens | Signed in |
PUT | /password | Change your own password. Body: {"current_password","new_password"} | Signed in |
GET | /sessions | Your sessions: id, created_at, expires_at, user_agent, ip_address | Signed in |
DELETE | /sessions/:id | Revoke one of your sessions | Signed in |
DELETE | /sessions/other | Revoke all your other sessions | Signed in |
POST | /switch-org | Switch the active organization. Body: {"org_id"} | platform.admin |
POST | /ldap/test | Test the configured LDAP bind | Signed in |
Password rules for PUT /password: at least 12 characters, and not one of the common passwords on the built-in deny list. It only works for local accounts.
LDAP and OIDC login
If an administrator has set up LDAP or OpenID Connect under Settings → Authentication, those users can get tokens too:
- LDAP:
POST /api/auth/ldapwith{"username","password"}(and optionalclient_type). The response is the same as/login. This works from scripts. - OIDC: a browser flow. Send the browser to
GET /api/auth/oidc/authorize?redirect_uri=<your URL>. After the identity provider login, the Controller redirects to yourredirect_uriwith the tokens in the URL fragment:#access_token=…&refresh_token=…&token_type=Bearer&expires_in=…. OIDC is not suitable for unattended scripts.
The first LDAP or OIDC login creates a NetStacks user in the default organization with no roles. An administrator must assign a role before that user can call anything beyond /api/auth/*. Groups and claims are not mapped to roles automatically.
For automation, a local user is the simplest choice. See Authentication settings.
Switching organization
Platform administrators can work inside another organization. Call POST /api/auth/switch-org with {"org_id":"<uuid>"} to get a new access token scoped to that organization:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 86400,
"active_org_id": "a5e7..."
}Use the new token for later calls. To switch back, send your home org_id, or refresh the token. Your refresh token stays the same. The switch is recorded in the audit log as auth.org_switched. To list organizations, use GET /api/admin/organizations.
Login rate limits
The /api/auth/* endpoints are rate limited per client IP address. No other endpoints are rate limited. The limits come from Settings → Security:
| Setting | Default | Effect |
|---|---|---|
security.rate_limit.requests_per_minute | 100 | Requests per minute to /api/auth/* from one IP |
security.rate_limit.failed_login_limit | 5 | Failed logins allowed from one IP within the window |
security.rate_limit.failed_login_window_minutes | 15 | Length of the failed-login window |
Over the limit, the API returns 429 with a Retry-After header and a body such as:
{
"error": "Too many failed login attempts. Please try again later.",
"retry_after_secs": 840
}Changes to these settings take effect after the API restarts.
Example: a script that stays logged in
A minimal Python pattern: log in once, retry once after refreshing on 401.
import requests
BASE = "https://controller.example.com:3000/api"
CA = "netstacks-ca.pem"
class NetStacks:
def __init__(self, username, password):
r = requests.post(f"{BASE}/auth/login", verify=CA,
json={"username": username, "password": password})
r.raise_for_status()
self._save(r.json())
def _save(self, tokens):
self.access = tokens["access_token"]
self.refresh = tokens["refresh_token"]
def _refresh(self):
r = requests.post(f"{BASE}/auth/refresh", verify=CA,
json={"refresh_token": self.refresh})
r.raise_for_status()
self._save(r.json()) # refresh tokens are single use
def request(self, method, path, **kw):
for attempt in (1, 2):
r = requests.request(method, f"{BASE}{path}", verify=CA,
headers={"Authorization": f"Bearer {self.access}"}, **kw)
if r.status_code == 401 and attempt == 1:
self._refresh()
continue
r.raise_for_status()
return r.json() if r.content else None
ns = NetStacks("automation", "<password>")
for d in ns.request("GET", "/devices", params={"limit": 100})["devices"]:
print(d["name"], d["host"])Troubleshooting
- 401 “Invalid credentials” for an LDAP user on /login:
/api/auth/loginonly accepts local accounts. LDAP users must use/api/auth/ldap. - 401 after a refresh worked before: the refresh token was already used, or you logged out somewhere (logout revokes all refresh tokens). Log in again.
- 403 “Insufficient permissions”: the user's roles lack the permission the endpoint needs. Check
permissionsinGET /api/auth/me. - 409 starting with “seat_limit_reached”: a
client_type: "terminal"login found no free license seat. Scripts should leaveclient_typeout. - SSL errors: download the Controller CA from
/api/tls/ca-certificateand pass it to your client (see Base URL and TLS).
To try endpoints by hand, open the Swagger UI, click Authorize, and paste the access_token value.