> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frostauth.cc/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK Login

> Authenticate an end-user account via the Frost Auth SDK.

# SDK Login

```http theme={null}
POST /api/v1/sdk/login
Content-Type: application/json
```

Authenticates an existing end-user (`AppUser`) with their username and password, binds the machine to a device, runs every gate, and returns a session token. Unlike `/api/v1/activate` (which is license-key-first), this route is credential-first — the user signs in, and the server picks the best license attached to their account.

> This is for **end-users signing into your application**, not operators signing into the console.

## Request

```json theme={null}
{
  "owner": "76fgh4d7",
  "product": "2d0a1800-ffa8-465d-8fcb-908816f.....",
  "username": "alex.dev",
  "password": "••••••••",
  "hwid": "b7d3f1a9c0e2...",
  "os": "Windows 11",
  "appVersion": "1.4.2",
  "hash": "9f2c...64 hex chars",
  "label": "Alex's desktop",
  "twofa": "123456",
  "components": { "cpu": "...", "mb": "..." },
  "virtual": false
}
```

The JS SDK fills `hwid`, `os`, `appVersion`, `components`, and `virtual` for you — pass only what the method doesn't already know:

```js theme={null}
import FrostAuth from "./frostauth.js";

const client = new FrostAuth({
  owner: "76fgh4d7",
  product: "2d0a1800-ffa8-465d-8fcb-908816f.....",
  version: "1.4.2",
  publicKey: { k2: "MCowBQYDK2VwAyEA7xdiHZqo4DmS4O1C8FavYq9Yqk5QbBkJiicb1MTTFzo" },
});

await client.init();
const snap = await client.login("alex.dev", "••••••••");
// snap.license, snap.product, snap.device, snap.account, snap.subscriptions
```

`extra` covers the rest: `client.login(user, pass, { twofa: "123456", hash, label: "Alex's desktop" })`.

## Parameters

| Field | Required | Description |
| - | - | - |
| `owner` | Yes | Owner account name (or ID). Products are scoped to this owner. |
| `product` | Yes | Product name, slug, or ID. |
| `username` | Yes | The end-user's username. Unique per product. |
| `password` | Yes | The end-user's password. Unknown user and wrong password give the same answer. |
| `hwid` | Yes | Hardware fingerprint for device binding. The SDK derives this automatically. |
| `os` | No | Operating system string. Feeds the platform gate. |
| `appVersion` | No | Current build version (max 32 chars). Feeds the version gate. |
| `hash` | No | SHA-256 of the running binary (max 128 chars). Checked when **Check Hash** is enabled. |
| `label` | No | Human-readable device name (max 64 chars). |
| `twofa` | Conditional | Six-digit TOTP code or recovery code. Required when the app user has two-factor enabled. |
| `components` | No | Structured hardware components object. Parsed into the composite HWID. |
| `virtual` | No | Boolean. Accepted but not currently enforced by any gate. |

`owner`, `product`, `hwid`, and `os` are capped at 256 characters. `username` is trimmed but not length-capped.

## Responses

```text 200 Success theme={null}
{
  "success": true,
  "response": {
    "data": {
      "session": {
        "token": "eyJhbG...MiIs...",
        "expiresIn": 900,
        "heartbeatSeconds": 180
      },
      "offline": {
        "assertion": "eyJ...k2",
        "notAfter": "2026-09-22T08:22:09.118Z",
        "keyId": "k2"
      },
      "license": {
        "status": "ACTIVE",
        "tier": "",
        "expiresAt": "2026-12-31T00:00:00.000Z",
        "activatedAt": "2026-08-01T14:22:09.118Z",
        "deviceLimit": 3,
        "devicesUsed": 1,
        "unlimitedDevices": false
      },
      "product": {
        "name": "Example Product",
        "version": "1.4.2",
        "minVersion": "1.3.0",
        "platform": "Windows",
        "updateAvailable": true,
        "latest": "1.4.2",
        "downloadUrl": "https://cdn.example.com/build.exe"
      },
      "device": {
        "id": "clx8s2k...",
        "os": "Windows 11"
      },
      "user": "alex.dev",
      "account": {
        "username": "alex.dev",
        "status": "Active",
        "twoFactor": true,
        "createdAt": "2026-08-01T14:00:00.000Z",
        "lastLoginAt": "2026-09-15T08:22:09.118Z",
        "lastIp": "123.0.123.0"
      },
      "subscriptions": [
        {
          "tier": "",
          "status": "ACTIVE",
          "expiresAt": "2026-12-31T00:00:00.000Z",
          "secondsLeft": 9244800
        }
      ]
    }
  }
}
```

```text 400 Bad Request theme={null}
{
  "error": true,
  "response": {
    "code": 400,
    "message": "Missing required fields",
    "errorCode": "MISSING_FIELDS"
  }
}
```

```text 400 Bad Request theme={null}
{
  "error": true,
  "response": {
    "code": 400,
    "message": "Malformed request body"
  }
}
```

```text 401 Unauthorized theme={null}
{
  "error": true,
  "response": {
    "code": 401,
    "message": "Invalid username or password",
    "errorCode": "INVALID_PASSWORD"
  }
}
```

```text 401 Two-Factor Required theme={null}
{
  "error": true,
  "response": {
    "code": 401,
    "message": "A two-factor code is required",
    "errorCode": "TWO_FACTOR_REQUIRED",
    "twoFactorRequired": true
  }
}
```

```text 401 Two-Factor Invalid theme={null}
{
  "error": true,
  "response": {
    "code": 401,
    "message": "Invalid two-factor code",
    "errorCode": "TWO_FACTOR_INVALID"
  }
}
```

```text 403 Forbidden theme={null}
{
  "error": true,
  "response": {
    "code": 403,
    "message": "This app is disabled",
    "errorCode": "APP_DISABLED"
  }
}
```

```text 403 Forbidden theme={null}
{
  "error": true,
  "response": {
    "code": 403,
    "message": "This account is banned",
    "errorCode": "USER_BANNED"
  }
}
```

```text 403 Forbidden theme={null}
{
  "error": true,
  "response": {
    "code": 403,
    "message": "This account is paused",
    "errorCode": "USER_PAUSED"
  }
}
```

```text 403 Forbidden theme={null}
{
  "error": true,
  "response": {
    "code": 403,
    "message": "License not found",
    "errorCode": "LICENSE_NOT_FOUND"
  }
}
```

```text 403 Forbidden (any gate code) theme={null}
{
  "error": true,
  "response": {
    "code": 403,
    "message": "configured message",
    "errorCode": "GATE_CODE"
  }
}
```

```text 404 Not Found theme={null}
{
  "error": true,
  "response": {
    "code": 404,
    "message": "App not registered for this owner",
    "errorCode": "APP_NOT_FOUND"
  }
}
```

```text 429 Rate Limited theme={null}
{
  "error": true,
  "response": {
    "code": 429,
    "message": "Too many attempts, try again in 4s",
    "errorCode": "RATE_LIMITED"
  }
}
```

```text 429 Rate Limited theme={null}
{
  "error": true,
  "response": {
    "code": 429,
    "message": "Too many requests, please try again later",
    "errorCode": "RATE_LIMITED",
    "retryAfterSeconds": 30
  }
}
```

```text 429 Rate Limited theme={null}
{
  "error": true,
  "response": {
    "code": 429,
    "message": "Too many login attempts"
  }
}
```

```text 429 Rate Limited theme={null}
{
  "error": true,
  "response": {
    "code": 429,
    "message": "Too many requests, slow down",
    "errorCode": "RATE_LIMITED"
  }
}
```

```text 500 Server Error theme={null}
{
  "error": true,
  "response": {
    "code": 500,
    "message": "Login failed"
  }
}
```

Two extras can ride along in `data` and the SDK handles both automatically:

* **`offline: { assertion, notAfter, keyId }`** — a signed offline assertion. The SDK caches it and `checkOffline()` can verify it locally against the pinned public key while the server is unreachable. Present when the server has a signing key configured and the login provided an HWID.
* **`product.updateAvailable: true, product.latest, product.downloadUrl?`** — a soft "a newer build exists" hint, sent only when the operator switched **autoUpdate** on and your `appVersion` is behind. The SDK re-emits it as an `updateAvailable` event (`{ current, latest, downloadUrl? }`). The hard version of the same decision is `forceLatest`, which refuses outright with `UPDATE_REQUIRED`.

`session` is only present when the operator's plan includes the token system. Without it, the response omits `session` and the SDK stays session-less.

## Behavior

* **Product resolution**: `owner` + `product` resolve by ID first, then name/slug fallback, scoped to the owner — IDs can't cross accounts. The owner must be `Active`, or the pair answers `APP_NOT_FOUND`.
* **Request order**: missing fields → product lookup → freshness (if `requireSignedRequests`) → lock check → user lookup → password verify → account status → 2FA → **clear failures** → license selection → gates.
* **Kill switch**: `appDisabled` is evaluated inside the gate pipeline (after auth), not before.
* **One refusal code for bad credentials**: unknown user and wrong password both answer `INVALID_PASSWORD`. Telling a stranger which half was wrong turns the endpoint into a username oracle.
* **Two-factor**: same module as the panel (`services/otp.services.js`). If enabled and no `twofa` sent, responds with `TWO_FACTOR_REQUIRED` + `twoFactorRequired: true`. Recovery codes are single-use.
* **License selection**: when an account holds multiple licenses on the product, usable ones (`ACTIVE`/`TRIAL`, unexpired) come first — perpetual keys first, then longest remaining expiry. If none are usable, the server falls back to sorting all of them the same way so the refusal names the real problem.
* **No license = refusal**: an account with zero licenses on this product gets `LICENSE_NOT_FOUND` (403). Gate failures (expired, revoked, blacklisted) get their own accurate code from `runGates` rather than a generic refusal.
* **Gates also cover**: product status (`Maintenance` → `MAINTENANCE`, anything not `Active`/`Beta` → `APP_NOT_FOUND`), platform mismatch, unregistered build hash, `forceLatest`/`minVersion` (`UPDATE_REQUIRED`), HWID/IP/license/username blacklist, geo policies, and version mismatch. The `blockVirtualMachines` setting is stored but **not currently enforced** by any gate.
* **Gate failures don't count as credential failures**: the per-account failure counter is cleared once the password verifies, before license lookup and gates run. A paying customer who needs to renew doesn't get locked out on top of it.
* **Session tokens**: JWT signed with HS512 carrying `lic` (license ID), `dev` (device ID), `pid` (product ID), `uid` (app user ID), and `jti` (for revocation). Lifetime is `LICENSE_TOKEN_TTL` (default 900s), heartbeat cadence is `HEARTBEAT_WINDOW` (default 180s).
* **Audit trail**: logins write `LicenseEvent` rows (`account <user> login`). No webhook fires on login.
* **SDK result**: `login()` stores the session/license/device/account/subscriptions and emits `loggedIn`, then `activated`. `client.snapshot()` returns `{ app, license, product, device, user, account, subscriptions, valid }`.

## Signed Responses

Every response carries Ed25519 signature headers when a signing key is configured (`v2` scheme — the signed input binds the exact request context):

| Header | Value |
| - | - |
| `X-Frost-Signature` | Base64url Ed25519 signature of `timestamp\nnonce\nmethod\npath\nstatus\nbody` |
| `X-Frost-Timestamp` | Unix millisecond timestamp |
| `X-Frost-Nonce` | Echoed from the request's `X-Frost-Nonce` header |
| `X-Frost-Key-Id` | Identifier of the signing key used |

The SDK sends a random `X-Frost-Nonce` per request, rejects answers signed more than 60 seconds ago, rejects answers to a different request, and refuses unsigned answers outright — except unsigned `429`s from the rate limiter, which it treats as retryable (`FrostAuthError` with `code: "RATE_LIMITED"`, `retryable: true`, and `retryAfterSeconds` from `Retry-After`). Failed calls throw `FrostAuthError` carrying the server's `errorCode` as `code`, plus `status` and `requestId`. `err.needsActivation` (404, non-retryable) and `err.terminal` (non-retryable 4xx) tell the heartbeat when to stop.

When `requireSignedRequests` is enabled for the product, inbound freshness is enforced **before** the handler runs: timestamp must be within 60 seconds, nonce must be 16–128 chars and unique (Redis `SET NX`, 180s TTL). Stale requests get `SESSION_EXPIRED`, replays get `FORBIDDEN`, missing timestamp/nonce gets `MISSING_FIELDS`.

## Rate Limits

| Layer | Limit | Keyed by |
| - | - | - |
| Login limiter | 20 per 15 min | Effectively per-IP (`anon:<ip>` since login sends no key/token) |
| IP floor | 60 per 1 min | IP |
| Per-identity backoff | 5 free attempts, then doubling from 2s to 15 min | Account + IP (`client:<product>:un:<user>:<ip>`) |
| Abuse guard | Escalating block after repeated failures | IP |

The per-identity lockout uses the **same module** as the panel (`services/panel.services.js`) but **different keys** (`client:...` vs `panel:...`) — lockouts do not cross between login and panel. The lockout answer embeds the wait in the message (`Too many attempts, try again in Ns`); the abuse-guard answer carries `retryAfterSeconds`. The login limiter's own answer (`Too many login attempts`) carries no `errorCode`.

## Errors

Operators can reword any message per product — match on `errorCode`, never on message text.

| HTTP | errorCode | Message (default) | Cause |
| - | - | - | - |
| `400` | `MISSING_FIELDS` | `Missing required fields` | `owner`, `product`, `username`, `password`, or `hwid` missing. |
| `400` | — | `Malformed request body` | JSON parse failure or shape assertion. No `errorCode`. |
| `401` | `INVALID_PASSWORD` | `Invalid username or password` | Wrong password or unknown user — indistinguishable. |
| `401` | `TWO_FACTOR_REQUIRED` | `A two-factor code is required` | Account has 2FA but no `twofa` sent. Carries `twoFactorRequired: true`. |
| `401` | `TWO_FACTOR_INVALID` | `Invalid two-factor code` | Code doesn't match or already consumed. |
| `403` | `APP_DISABLED` | `This app is disabled` | Kill switch is on (evaluated in gates). |
| `403` | `USER_BANNED` | `This account is banned` | App user status is Banned. |
| `403` | `USER_PAUSED` | `This account is paused` | App user status is Paused. |
| `403` | `LICENSE_NOT_FOUND` | `License not found` | Account holds no license on this product. |
| `403` | (gate code) | (configured message) | Any activation gate failure. |
| `404` | `APP_NOT_FOUND` | `App not registered for this owner` | Owner/product pair doesn't resolve. |
| `403` | `SESSION_EXPIRED` | `Session expired` | Request timestamp skew > 60s when `requireSignedRequests` is on. |
| `403` | `FORBIDDEN` | `Access forbidden` | Replayed nonce when `requireSignedRequests` is on. |
| `400` | `MISSING_FIELDS` | `Missing required fields` | Missing timestamp or nonce when `requireSignedRequests` is on. |
| `429` | `RATE_LIMITED` | `Too many attempts, try again in Ns` | Per-identity lockout active. |
| `429` | `RATE_LIMITED` | `Too many requests, please try again later` | Abuse guard block. Carries `retryAfterSeconds`. |
| `429` | — | `Too many login attempts` / `Too many requests, slow down` | Route/IP limiters. No `errorCode`. |
| `500` | — | `Login failed` | Unhandled exception. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.