This file is optimized for AI coding agents (Cursor, Copilot, Claude Code, etc.). It contains everything needed to correctly integrate AuthForge licensing into a project.
AuthForge is a license key validation service. Your app activates online: it sends a license key + hardware ID to POST /auth/validate, and the server checks revocation, expiry, HWID binding, and credits, then returns an Ed25519-signed session with a TTL.
After activation the client follows one of two policies:
- Grace period (default): the app keeps running on the signed session without contacting AuthForge. The SDK re-verifies the signature and expiry locally in the background and fails once the session TTL expires. The grace period equals the session TTL: default 24h, server clamps requests to 1h to 7d (
ttlSeconds). - Online check-ins (opt-in,
onlineHeartbeat: true): the SDK periodically callsPOST /auth/heartbeatfor fast revocation and concurrent-use detection.
If the license is revoked or expired, the background check fails and you handle it (typically exit the app).
There is also a separate mode for machines that can never reach the internet: offline license files (.authforge). The operator mints a signed file in the AuthForge cloud; loginFromFile() verifies it locally with the app public key and the machine HWID, with zero network calls. Do not ship the App Secret in those builds (omit appSecret). Only use it when the user explicitly asks for air-gapped / offline-file licensing. The default integration is always online login() + grace period. To collect the HWID for a bound file, write an activation request (.authforge-request) with createActivationRequest / writeActivationRequest. It is not a license, is not signed, and does not mint anything. Prefer it over printing the raw HWID.
A third, also separate mode is self-serve offline activation (activateOffline()), for apps sold as a one-time purchase: on first run the end user enters their key, one online call (POST /auth/offline/activate) returns a lifetime .authforge file bound to that machine, and from then on the app runs from the file with loginFromFile() and no network. Unlike air-gapped files it needs the App Secret in the build (it is an online call); air-gapped builds still ship with no app secret. The app owner must opt in on the app, and only perpetual, seat-limited licenses qualify. Only use it when the user asks for one-time-purchase / activate-once offline licensing.
- 1
login()orvalidateLicense()= 1 credit (one/auth/validatedebit). - 10 online check-ins = 1 credit (billed on every 10th successful check-in per license). The grace period costs nothing after the initial activate.
- 1 offline file mint = 1 credit (charged to the operator when the file is minted).
loginFromFile()/verifyLicenseFile()cost nothing. - 1 new offline activation = 2 credits; re-activating the same machine is free (
activateOffline(), charged to the app owner; a refused activation costs nothing). - Keep
heartbeatIntervalat>= 10seconds (900/ 15 min is the common desktop default)./auth/heartbeatis limited to 6 requests/minute per license key, and revocations still take effect on the next check-in.
Install from npm as @authforgecc/sdk:
npm install @authforgecc/sdkOr copy authforge.mjs into your project (single file, Node.js built-ins only). Requires Node.js 18+.
import process from "node:process";
import { AuthForgeClient, AuthForgeError } from "@authforgecc/sdk";
// Aborted when the license is lost; the app listens and shuts down cleanly.
const licenseLost = new AbortController();
const onFailure = (reason, error) => {
if (reason === "heartbeat_failed" && error instanceof AuthForgeError && error.transient) {
return; // network blip / rate_limited: the SDK checks in again next interval
}
console.error(`AuthForge: ${reason}`);
if (error) {
console.error(error);
}
licenseLost.abort(error); // do not process.exit() here: let the app save first
};
const client = new AuthForgeClient({
appId: "YOUR_APP_ID",
appSecret: "YOUR_APP_SECRET",
publicKey: "YOUR_PUBLIC_KEY",
onFailure,
});
const licenseKey = process.argv[2]?.trim();
if (!licenseKey) {
console.error("Provide a license key.");
process.exit(1);
}
const ok = await client.login(licenseKey);
if (!ok) {
console.error("Login failed.");
process.exit(1);
}
licenseLost.signal.addEventListener(
"abort",
() => {
// Save the user's work and close servers/timers here; Node then exits on its own.
client.logout();
process.exitCode = 1;
},
{ once: true },
);
// --- Your application code starts here ---
console.log("Running with a valid license.");
// Long-running work should watch licenseLost.signal (pass it to fetch(), or check
// licenseLost.signal.aborted between steps) and stop cleanly when it fires.
// --- Your application code ends here ---
client.logout();This default configuration activates online once and then runs through the grace period (no further network calls until the session TTL expires). To enable online check-ins, add onlineHeartbeat: true (and optionally tune heartbeatInterval).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
appId |
string |
yes | - | Application ID |
appSecret |
string |
for online APIs | - | Application secret. Required for login / validateLicense / selfBan / activateOffline. Omit or pass "" for loginFromFile only; do not ship it in air-gapped binaries. |
publicKey |
string | readonly string[] |
yes | - | Base64 Ed25519 public key from the dashboard (3rd positional arg, or publicKey in the options object). Accepts one key, an array, or a comma-separated string; the SDK trusts a signature matching any entry (key rotation) |
onlineHeartbeat |
boolean |
no | false |
Enable online check-ins (periodic /auth/heartbeat). When false, the app runs through the grace period without contacting AuthForge |
heartbeatMode |
string |
no | - | Deprecated. "SERVER" or "LOCAL" (case-insensitive); still the 4th positional arg for compatibility. See Migrating from heartbeatMode |
heartbeatInterval |
number |
no | 900 |
Seconds between background checks (minimum 10) |
apiBaseUrl |
string |
no | https://auth.authforge.cc |
API base URL |
onFailure |
(reason: string, error: Error | null) => void | null |
no | null |
Called on login/check-in/network failure. If omitted, a transient background check failure prints a one-line warning to stderr and check-ins continue; any other failure exits via process.exit(1) |
requestTimeout |
number |
no | 15 |
HTTP timeout (seconds) |
ttlSeconds |
number | null |
no | null (server default: 86400) |
Requested grace period duration (session TTL) in seconds. Server clamps to [3600, 604800] (1h to 7d) and preserves the lifetime across check-in refreshes. |
hwidOverride |
string | null |
no | null |
Optional custom HWID/subject string. When set to a non-empty value (for example tg:123456789), the SDK sends it instead of generating a machine fingerprint. |
For Telegram/Discord bot flows, prefer immutable IDs (tg:<user_id>, discord:<user_id>) instead of usernames.
heartbeatMode is deprecated but still accepted (options object or 4th positional arg). Passing it emits a DeprecationWarning; an invalid value still throws heartbeatMode must be LOCAL or SERVER.
"LOCAL"maps to the default grace period behavior: remove the option."SERVER"maps toonlineHeartbeat: true.
// Before
new AuthForgeClient({ appId, appSecret, publicKey, heartbeatMode: "SERVER" });
// After
new AuthForgeClient({ appId, appSecret, publicKey, onlineHeartbeat: true });The client exposes onlineHeartbeat (boolean, the effective policy) and keeps a back-compat heartbeatMode property ("SERVER" when check-ins are enabled, "LOCAL" otherwise). Prefer onlineHeartbeat.
| Method | Returns | Description |
|---|---|---|
login(licenseKey) |
Promise<boolean> |
Activates online: validates license, verifies signatures, starts the background check interval |
validateLicense(licenseKey) |
Promise<ValidateLicenseResult> |
Same validate + signatures as login; does not mutate session or start background checks; never calls onFailure or process.exit: use the result object |
loginFromFile(pathOrText) |
boolean |
Offline mode: verifies a .authforge file locally (no network), authenticates the client, never starts background checks. Failures -> onFailure("offline_login_failed", error) + false; never process.exit |
activateOffline(licenseKey, { path }?) |
Promise<boolean> |
Self-serve offline activation: one POST /auth/offline/activate returns a lifetime file bound to this HWID; verified locally, written atomically to path, then an offline session starts (as loginFromFile). Needs the app secret. Failures reject with AuthForgeError; never onFailure / process.exit |
verifyLicenseFile(pathOrText, options?) |
VerifyLicenseFileResult |
Same offline checks without changing client state |
getOfflineLicense() |
OfflineLicenseSummary | null |
jti, expiresAt, hwidPolicy, … of the offline file in use |
getSessionKind() |
"online" | "offline" | null |
Kind of session the client holds; null when logged out |
getHwid() |
string |
HWID this client sends; the customer reports it so the operator can mint a bound file |
createActivationRequest(options?) |
string |
Unsigned .authforge-request for this machine. No network, no secret, callable before login(). Hostname omitted unless includeMachineName: true |
writeActivationRequest(path, options?) |
void |
Writes that file as UTF-8 |
logout() |
void |
Stops background checks and clears session state |
isAuthenticated() |
boolean |
Whether a session token is present and marked authenticated |
getSessionData() |
Record<string, unknown> | null |
Decoded signed payload map |
getAppVariables() |
Record<string, unknown> | null |
App-scoped variables |
getLicenseVariables() |
Record<string, unknown> | null |
License-scoped variables |
Full set (knownServerErrors): invalid_app, invalid_key, expired, revoked, hwid_mismatch, no_credits, app_burn_cap_reached, blocked, rate_limited, replay_detected, app_disabled, session_expired, revoke_requires_session, bad_request, malformed_request, demo_quota_exceeded, system_error, offline_activation_disabled, offline_activation_requires_perpetual, offline_activation_requires_seats, offline_activation_limit_reached, offline_file_rejected (SDK code). Unrecognized codes are passed through verbatim.
Notes:
replay_detectedis validate-only.rate_limitedcan be returned by/auth/validateand/auth/heartbeat(heartbeat is license-limited at 6/min and has no app-layer IP limit)./auth/heartbeatreturnshwid_mismatchwhen the HWID is no longer bound to the license (for example after an HWID reset) andblockedwhen the HWID/IP is blacklisted or not whitelisted.app_burn_cap_reachedmeans the app's configured credit burn cap is hit;revoke_requires_sessionmeans a pre-session self-ban tried to revoke a license (only session-authenticated self-ban can revoke).session_expiredis also raised locally when the grace period (session TTL) runs out.
Heartbeat failure classification (AuthForgeError.transient / isTransientError()):
- Definitive (allowlist, exported as
definitiveErrorCodes):revoked,expired,hwid_mismatch,blocked,session_expired,malformed_request,app_disabled,invalid_app,signature_mismatch, plus the self-serve activation codesoffline_activation_disabled,offline_activation_requires_perpetual,offline_activation_requires_seats,offline_activation_limit_reached,offline_file_rejected. The SDK clears the stored session and stops check-ins before callingonFailure. - Transient: everything else, including
network_error,timeout,rate_limited,system_error,no_credits,demo_quota_exceeded,app_burn_cap_reached,bad_request,invalid_key,unexpected_response, everyhttp_error_<status>and unknown codes. The session is kept and the SDK checks in again next interval; after the session TTL passes, the next transient failure becomes a definitivesession_expired. - A failed check-in is a verdict only when its body is
{"status":"failed","error":"<code>"}; any other failure body isunexpected_response(rawstatus/errorin the message). - Only
rate_limited(or a 429 with no error code) is retried inside the request (2s, then 5s).no_credits,demo_quota_exceededandapp_burn_cap_reachedare never retried immediately. onFailuremay calllogout(),isAuthenticated()orlogin(). A check-in still in flight whenlogout()/login()runs is discarded.
const varsMap = client.getLicenseVariables() ?? {};
const tier = varsMap.tier;client.logout();// Step 1 (customer machine): print the HWID so the operator can bind the file to it.
console.log(client.getHwid());
// Step 2 (operator): mint the .authforge file in the dashboard or via
// POST /v1/licenses/{licenseKey}/offline-files and deliver it out-of-band.
// Step 3 (customer machine): authorize with the file. No network, no check-ins.
if (!client.loginFromFile("./license.authforge")) {
// onFailure already received ("offline_login_failed", Error(code)) where code is one of
// bad_armor | bad_signature | unsupported_version | malformed_payload | wrong_app | expired | hwid_mismatch
process.exit(1);
}Offline file error codes (in check order): bad_armor, bad_signature, unsupported_version, malformed_payload, wrong_app, expired, hwid_mismatch.
Separate from the air-gapped flow above: this build ships the App Secret (activation is an online call), the app owner has enabled self-serve offline activation on the app, and the licenses are perpetual and seat-limited. 1 new offline activation = 2 credits (charged to the app owner); re-activating the same machine is free and returns the identical file.
import { existsSync } from "node:fs";
import { AuthForgeClient, AuthForgeError } from "@authforgecc/sdk";
const LICENSE_PATH = "./license.authforge"; // a writable per-user location
const client = new AuthForgeClient({
appId: "YOUR_APP_ID",
appSecret: process.env.AUTHFORGE_APP_SECRET, // required for activateOffline()
publicKey: "YOUR_PUBLIC_KEY",
onFailure: () => {}, // loginFromFile() reports here; activateOffline() throws instead
});
if (!(existsSync(LICENSE_PATH) && client.loginFromFile(LICENSE_PATH))) {
const key = await promptForKey(); // app UI
try {
await client.activateOffline(key, { path: LICENSE_PATH }); // verifies, then writes atomically
} catch (error) {
if (!(error instanceof AuthForgeError)) throw error;
if (error.code === "offline_activation_limit_reached") {
showError("This key is activated on its maximum number of computers. Please contact the vendor.");
} else if (error.code === "invalid_key") {
showError("That license key is not valid."); // classified transient, but a retry needs a different key
} else if (error.transient) {
showError("Activation failed, please try again later."); // network, rate_limited, no_credits, ...
} else {
showError(`Activation refused: ${error.code}`); // revoked, hwid_mismatch (no free seat), ...
}
}
}
// On success: getSessionKind() === "offline", no background checks; next launch takes the loginFromFile() branch.Codes (all fatal): offline_activation_disabled (app has not opted in), offline_activation_requires_perpetual, offline_activation_requires_seats (shared keys cannot self-serve), offline_activation_limit_reached (every machine slot used; HWID resets do not free them; tell the user to contact the vendor), offline_file_rejected (SDK code: the returned file failed local verification and was not written; the verifier's reason is in error.message). The server's hwid_mismatch here means no free seat. file_write_failed (transient) means the verified file could not be saved; activating again is free. Existing codes keep their meaning and classification.
Server error codes appear as AuthForgeError in the error passed to onFailure: error.code is the code (for example invalid_key, revoked, hwid_mismatch), error.message the message. Reasons are login_failed, heartbeat_failed, or offline_login_failed. A login() network failure is reported once, as login_failed with error.code network_error or timeout. For heartbeat_failed, error is always an AuthForgeError; use error.transient to tolerate outages. selfBan() never calls onFailure: every failure, including a network one (error.code network_error or timeout), rejects the returned promise.
import { AuthForgeError } from "@authforgecc/sdk";
const licenseLost = new AbortController(); // the app listens for "abort", saves work, then exits
const onFailure = (reason, error) => {
if (reason === "heartbeat_failed" && error instanceof AuthForgeError && error.transient) {
return; // no verdict (network, rate_limited, no_credits, ...): SDK retries next interval
}
// Definitive heartbeat failures have already cleared the session.
const code = error?.code ?? error?.message;
if (new Set(["invalid_key", "expired", "revoked", "hwid_mismatch", "blocked"]).has(code)) {
console.error(`License issue: ${code}`);
}
licenseLost.abort(error);
};process.exit(1) inside onFailure is a last resort: it does not wait for pending writes, so save the user's work first.
- Do not hardcode the app secret as a plain string literal in source - use environment variables or encrypted config
- Do not embed the App Secret in air-gapped /
loginFromFilebuilds - omit it or pass""; verification only needs app id + public key - Do not skip the
onFailurecallback - without it, transient check-in failures only print a stderr warning, but definitive ones (and a failedlogin()) terminate the process viaprocess.exit(1)without your cleanup - Do not call
process.exit()fromonFailureas the normal shutdown path - signal the app (for example anAbortController) so it can save work and exit cleanly - Do not treat every
heartbeat_failedas a network blip or every one as fatal - checkerror.transient(only thedefinitiveErrorCodesallowlist is fatal). Fatal failures have already cleared the session, so do not keep the app running on it - Do not call
login()on every app action - call it once at startup; the background checks handle the rest - Do not use
heartbeatModein new code - it is deprecated; useonlineHeartbeat: truewhen you need online check-ins, or nothing at all for the default grace period - Do not treat the grace period as persistent offline licensing - it is session continuation after one successful online activation, and revocations are only picked up at the next online validate or check-in
- Do not reach for
loginFromFile()unless the user explicitly needs air-gapped / offline-file licensing - the default is onlinelogin()+ grace period - Do not expect an online revoke to disable an offline file that is already on a customer machine - the file stays valid until its own
expiresAt; prefer short expiries and HWID-bound files - Do not use
activateOffline()for air-gapped builds or confuse it with them - it needs the App Secret and one online call; air-gapped builds use operator-minted files and ship no secret. Files fromactivateOffline()are lifetime: a refund, chargeback or revoke cannot reach them - Do not call
activateOffline()on every launch - tryloginFromFile(path)first and activate only when there is no file or it fails; each new machine costs 2 credits (same machine is free) - Do not treat the server's
hwid_mismatchfromactivateOffline()as a local file problem - it means no free seat; a file that fails local checks isoffline_file_rejected - Do not mint or accept
hwid.mode: "any"files casually - anyone who copies an unbound file has a working license - Do not call
loginFromFile()with another app's public key or app id - the file is rejected withbad_signature/wrong_appby design - Do not try to build
.authforgefiles client-side - only the AuthForge cloud holds the signing key; there is no BYO issuer - Do not call
selfBan()or any other online method afterloginFromFile()- an offline session has no server session (getSessionKind()is"offline"), soselfBan()rejects withoffline_sessionwithout contacting the server and online check-ins never start; machines that can reach AuthForge should use onlinelogin() - Do not bind an offline file to an HWID collected before upgrading to 2.0 - HWID v2 replaced the old fingerprint, so pre-2.0 HWIDs no longer match; collect it again with
createActivationRequest()(since 2.0 any AuthForge SDK gives the same HWID for the same app id and machine)
The HWID is sha256("authforge-hwid-v2|" + appId + "|" + machineId) (lowercase hex), where machineId is the trimmed, lowercased OS machine id: macOS IOPlatformUUID, Linux /etc/machine-id (then /var/lib/dbus/machine-id), Windows MachineGuid (64-bit registry view). Without a valid OS id the SDK falls back to legacy:<pre-2.0 fingerprint>. For containers, use hwidOverride.
hwid_vectors.json is generated by authforge-node/generate_hwid_vectors.mjs. Regenerating it means copying the file unmodified into all six SDK repos in the same change.
activation_request_vectors.json is generated by authforge-node/generate_activation_request_vectors.mjs. Regenerating it means copying the file unmodified into all six SDK repos and platform/frontend/src/test/fixtures/activation_request_vectors.json in the same change. The vector sdk value is a frozen encoding fixture, not the live SDK tag.