Login

POST /v1/auth/login

Exchange an email (or username) and password for a JWT. The identifier is email-first: pass either your email address or your username in the username field — the server matches both and prefers an exact email match. The JWT is then sent in the Authorization: Bearer <token> header for subsequent requests; alternatively, mint an API key for long-running automation.

If the user has MFA enabled, this endpoint returns {"mfa_required": true, "mfa_challenge_token": "…"} with HTTP 200 instead of a JWT — no token is issued. Your client then calls POST /v1/auth/mfa with that mfa_challenge_token plus the TOTP code (the challenge is short-lived — see mfa_expires_in).

Sign-in limits. Attempts are counted per login name + client IP and per client IP: too many within 5 minutes lock that pair, or that IP, for 15 minutes. Across IPs a login name is only slowed down, never locked: once it has had too many wrong passwords, each further attempt from an IP must first wait a delay that doubles up to 30 seconds. Every refusal is a 429 with Retry-After. A successful sign-in sets a known-device cookie, __Host-ngris_login_device, valid 30 days from that sign-in: a later attempt that carries it for the same user skips the per-IP limit and the delay (the name + IP limit always applies). The dashboard keeps this cookie in your browser; a script calling this endpoint gets the normal limits unless it stores the cookie and sends it back. Changing or resetting the password ends the exemption for every browser; the next successful sign-in issues a new cookie.

Email verification is enforced — accounts with email_verified: false get 403 with code: "EMAIL_NOT_VERIFIED". If the user's active account enforces SSO for their email domain (via a domain-verified, enforced SSO config), password login is refused before the password is checked with 403 code: "SSO_REQUIRED" — the body carries sso_required, sso_provider, email_domain, and init_url so the client can redirect the user to their identity provider.

MFA still applies after SSO. If a user signs in through their identity provider but also has MFA enabled, they must complete the TOTP second factor before a session is minted — the SSO callback issues its own mfa_challenge_token for POST /v1/auth/mfa.

Request body

username string Req
Email address or username. The identifier is email-first — the server matches either and prefers an exact email match. (The JSON field is named username for historical reasons but accepts either value.)
password string Req
Plaintext password. Compared with the bcrypt hash server-side. Random delay (100-250ms) is added on failure to flatten timing-based username enumeration.

Response — success (200 OK)

token string
Signed JWT. Send as Authorization: Bearer <token> on subsequent requests. It is valid until expires_at. The token is scoped to one active account (carried in its claims); to act in a different account, mint a new token via POST /v1/user/switch-account. Don't decode the token to learn the account — read its uuid from GET /v1/account.
expires_at, expires_in datetime, int
When the session token expires (RFC 3339, UTC) and the seconds left until then.
user object
The signed-in user, as a fixed list of fields: uuid, username, email, email_verified, pending_email (an email change awaiting confirmation; omitted otherwise), role, status, mfa_enabled, created_at, updated_at, the active account (active_account_uuid and active_account_name, each omitted when unknown) and its plan slug. It also carries that plan's limit fields (max_tunnels, max_endpoints, max_connections, bandwidth_limit, request_limit, allowed_protocols, allowed_regions, allow_organizations), left out of the example — read the account's limits from GET /v1/account/plan — and balance_cents, which the sign-in doesn't load: it is always 0 here. Nothing else: read the full profile (last_login_at included) from GET /v1/user.

Deprecated: until the sunset the user object also carries the legacy integer id and *_id fields. Don't use them.

Response — MFA required (200 OK)

mfa_required boolean
Always true. No token is issued — call /auth/mfa next.
mfa_challenge_token string
One-time, short-lived challenge token. Pass it (with the TOTP code) to /auth/mfa so the second factor doesn't re-take username/password. Only its hash is stored server-side.
mfa_expires_in integer
Seconds until mfa_challenge_token expires (currently 300 = 5 minutes). After that the client must log in again.
user object
Partial user profile (uuid, username, email, role) for UI hints — do not trust as authenticated until MFA succeeds. (It also carries the legacy integer id until the sunset; don't use it.)
message string
Human-readable hint, e.g. "MFA verification required".

Errors

400 Bad Request
Invalid JSON, missing username or password, or any unknown field (e.g. email or mfa_code — pass an email address in the username field, and send the TOTP code to /auth/mfa instead; the handler uses strict decoding).
401 Unauthorized
Wrong credentials. The same message ("Authentication failed") is used for both unknown user and wrong password to prevent enumeration.
403 Forbidden
Email not verified (code: "EMAIL_NOT_VERIFIED") — direct user to /auth/resend-verification. Or the user's active account enforces SSO for their email domain (code: "SSO_REQUIRED", with sso_required, sso_provider, email_domain, and init_url set) — send the user to init_url to start SSO.
429 Too Many Requests
A sign-in limit was hit ("Too many login attempts. Please try again later."): the login name at this IP, this IP, or the login name's delay. Wait the Retry-After seconds before retrying.
503 Service Unavailable
The pre-auth SSO-enforcement lookup failed (code: "SSO_CHECK_UNAVAILABLE"). Transient — retry shortly.
Request
curl -X POST "https://api.ngris.com/v1/auth/login" \ -H "Content-Type: application/json" \ -d '{ "username": "alice@example.com", "password": "s3cur3p@ss!" }'
Python
from ngris import Ngris # api_key is irrelevant for /auth/login but the SDK requires one; # any non-empty value works. client = Ngris(api_key="placeholder") resp = client.auth.login(username="alice", password="s3cur3p@ss!") if resp.mfa_required: code = input("MFA code: ") resp = client.auth.mfa_verify("alice", "s3cur3p@ss!", code) print(resp.token)
Response — 200 OK (abridged)
{ "token": "eyJhbGciOiJIUzI1NiI...", "expires_at": "2026-10-21T09:00:00Z", "expires_in": 86400, "user": { "uuid": "019a52c2-ca1e-78ba-ab5c-50362cc29134", "username": "alice", "email": "alice@example.com", "role": "user", "plan": "team", "status": "active", "created_at": "2026-01-15T12:00:00Z", "updated_at": "2026-10-02T08:41:17Z", "email_verified": true, "mfa_enabled": false, "active_account_name": "Acme Robotics", "active_account_uuid": "019a68b9-1a79-7820-a884-41f554aba6bd" } }
Response — MFA required
{ "success": false, "mfa_required": true, "mfa_challenge_token": "b3f1c2a9d4e5...", "mfa_expires_in": 300, "message": "MFA verification required", "user": { "uuid": "019a52c2-ca1e-78ba-ab5c-50362cc29134", "username": "alice", "email": "alice@example.com", "role": "user" } }
Error — email not verified
{ "error": "Please verify your email before logging in", "code": "EMAIL_NOT_VERIFIED", "resend_token": "c0f5…" }
Error — SSO required (403)
{ "error": "Password login is disabled for this domain. Please use SSO.", "code": "SSO_REQUIRED", "sso_required": true, "sso_provider": "oidc", "email_domain": "example.com", "init_url": "/auth/sso/init" }
Was this helpful?
On this page
Login
Was this helpful?