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

# Offline license files (.authforge)

> Cloud-minted, Ed25519-signed license files for air-gapped machines that never contact AuthForge. How they differ from the grace period, how to mint one, and how every SDK verifies it.

An **offline license file** is a signed document (`.authforge`) that you mint in the AuthForge cloud and hand to a customer whose machine never connects to the internet. The SDK on that machine verifies the file with **only your app public key, app id, and its own HWID**. It never calls `/auth/validate`, never checks in, and never needs AuthForge network access for the life of the file. **Do not embed the App Secret** in air-gapped binaries: `loginFromFile` does not use it. Pass an empty secret (or omit it in languages that allow that) when constructing an offline-only client.

<Warning>
  This is a **separate mode** from the default. Most apps should keep using online activation + the [grace period](/concepts#activation-and-the-grace-period). Reach for offline files only when a machine genuinely cannot phone home (factory floors, classified networks, ships, embedded appliances).
</Warning>

## Offline file vs grace period

|                            | Grace period (default)                            | Offline license file                                                      |
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------- |
| Network on the end machine | Once, at `login()`                                | **Never**                                                                 |
| What is verified           | Signed *session* returned by `/auth/validate`     | Signed *document* minted by an operator                                   |
| Who creates it             | The SDK, automatically                            | You, in the dashboard or Developer API                                    |
| Lifetime                   | Session TTL: 1h to 7d                             | Your chosen expiry, or lifetime (perpetual licenses only)                 |
| HWID binding               | Server binds seats at activation                  | Baked into the file: bound to specific HWIDs, or (explicitly) any machine |
| Revocation                 | Picked up at the next online validate or check-in | **Not reachable.** The file stays valid until its own expiry              |
| Cost                       | 1 credit per `login()`                            | 1 credit per mint. Verifying is free                                      |
| SDK call                   | `login(licenseKey)`                               | `loginFromFile(pathOrText)`                                               |

The grace period is *session continuation*: it keeps an app running for a while after an online activation. An offline file is *persistent air-gap licensing*: a standalone credential the machine can verify forever without you.

## Revocation: read this before you mint

Revoking a license online blocks **new** mints and stops online activations. It does **not** reach files that are already on customer machines. Every issued `.authforge` file stays valid until its own `expiresAt` (or forever, for lifetime files). There is no remote kill switch, and we do not pretend otherwise.

Practical consequences:

* Prefer **short expiries** (30 to 90 days) and re-issue on a schedule. Treat re-issuing as your revocation lever.
* Prefer **HWID-bound** files. An unbound (`any`) file works on every machine that has a copy.
* Mint **lifetime** files only for perpetual licenses where you accept that the entitlement is permanent.
* Keep mint history (the dashboard shows it per license) so you know exactly which files exist and when they lapse.

## Minting a file

### Dashboard

1. Open the license (Applications -> app -> Licenses -> license page) and click **Mint .authforge file**.
2. Pick an expiry. Presets are 30 days, 90 days, 1 year, the license expiry, or Lifetime (perpetual licenses only). The file can never outlive the license.
3. Pick a machine binding. **Bound to specific HWIDs** (recommended) prefills the HWIDs already bound to the license. Prefer an [activation request](/guides/activation-requests) (`.authforge-request`) over a pasted HWID: the dashboard checksums the file so email damage becomes a clear error instead of a silent `hwid_mismatch`. You can still paste the getter (`getHwid()` / `get_hwid()` / `HWID()` / `hwid()` / `GetHwid()`). **Any machine** requires an explicit acknowledgement.

   <Warning>
     **Collect the HWID from the same SDK (and language) that will call `loginFromFile`.** HWID fingerprints are [not portable across SDKs](/features/hwid-locking): a machine reports a different value from the Node SDK than from the C# SDK. A file bound to the wrong one fails with `hwid_mismatch`. If your product uses several SDKs on the same box, pass `hwidOverride` with an identifier you control instead.
   </Warning>
4. Click **Mint & download**. The browser downloads `<licenseKey>.authforge` and 1 credit is charged. The file body is never stored server-side, only its SHA-256, so download it now.

The row menu on the licenses table has the same action, and the license page lists every mint (issued, expires, binding, source, file id).

### Developer API

```bash theme={null}
curl -X POST https://api.authforge.cc/v1/licenses/XXXX-XXXX-XXXX-XXXX/offline-files \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "expiresAt": "2027-01-01T00:00:00Z",
    "hwidMode": "bound",
    "hwids": ["3f1c...e9a2"]
  }'
```

Returns `201` with `{ file, fileName, meta }`. `file` is the armored text; write it to `fileName`. Scope `write:licenses`. See [Licenses API -> Offline license files](/api/licenses#offline-license-files) for the full reference and error codes.

### Mint rules

* License must be `active` and not expired; the app must not be paused.
* `expiresAt` must be in the future and on or before the license expiry. `null` (lifetime) is only accepted for licenses without an expiry.
* `hwidMode: "bound"` needs 1 to 16 HWIDs. `hwidMode: "any"` needs `allowUnbound: true`.
* Rate limited: 30 mints per minute per account, 10 per minute per license.
* **Billing:** 1 credit per successful mint, debited through the same path as `/auth/validate` (so your app's hourly/daily burn caps apply). Rejected mints are free. A mint fails with `no_credits` before anything is signed.
* Every mint is audited (actor, time, license, expiry, HWID policy, file id and hash).

## Verifying on the air-gapped machine

Every official SDK (1.2.0+) ships two things:

* `verifyLicenseFile(...)`: a pure function that checks a file and returns the decoded license or an error code. No client state, no network.
* `client.loginFromFile(pathOrText)`: verifies with the client's configured app id, public key(s) and HWID, then marks the client authenticated so `isAuthenticated()`, `getLicenseVariables()` and `getAppVariables()` work exactly as after an online `login()`. It **never** starts the grace-period timer or online check-ins. `logout()` clears it.

<CodeGroup>
  ```python Python theme={null}
  from authforge import AuthForgeClient

  client = AuthForgeClient(app_id="APP_ID", app_secret=None, public_key="PUBLIC_KEY",
                           on_failure=lambda reason, exc: print(reason, exc))

  print("HWID:", client.get_hwid())  # customer sends this to you before you mint

  if client.login_from_file("license.authforge"):
      info = client.get_offline_license()
      print("valid until", info["expires_at"] or "forever", client.get_license_variables())
  ```

  ```js Node.js theme={null}
  import { AuthForgeClient } from "@authforgecc/sdk";

  const client = new AuthForgeClient({ appId: "APP_ID", publicKey: "PUBLIC_KEY",
    onFailure: (reason, error) => console.error(reason, error?.message) });

  console.log("HWID:", client.getHwid());

  if (client.loginFromFile("./license.authforge")) {
    console.log("valid until", client.getOfflineLicense().expiresAt ?? "forever", client.getLicenseVariables());
  }
  ```

  ```go Go theme={null}
  client, _ := authforge.New(authforge.Config{AppID: "APP_ID", PublicKey: "PUBLIC_KEY"})
  fmt.Println("HWID:", client.HWID())

  lic, err := client.LoginFromFile("license.authforge")
  if errors.Is(err, authforge.ErrOfflineExpired) {
  	log.Fatal("offline license expired - request a new file")
  } else if err != nil {
  	log.Fatal(err)
  }
  fmt.Println("valid until", lic.ExpiresAt) // nil = lifetime
  ```

  ```rust Rust theme={null}
  let client = AuthForgeClient::new(AuthForgeConfig {
      app_id: "APP_ID".into(), public_key: "PUBLIC_KEY".into(),
      ..Default::default()
  });
  println!("HWID: {}", client.hwid());

  match client.login_from_file("license.authforge") {
      Ok(lic) => println!("valid until {:?}", lic.expires_at), // None = lifetime
      Err(OfflineLicenseError::HwidMismatch) => eprintln!("this file is bound to another machine"),
      Err(err) => eprintln!("rejected: {err}"),
  }
  ```

  ```csharp C# theme={null}
  var client = new AuthForgeClient(appId: "APP_ID", appSecret: "", publicKey: "PUBLIC_KEY",
      onFailure: (reason, ex) => Console.Error.WriteLine($"{reason}: {ex?.Message}"));
  Console.WriteLine($"HWID: {client.GetHwid()}");

  if (client.LoginFromFile("license.authforge"))
  {
      Console.WriteLine($"valid until {client.GetOfflineLicense()!.ExpiresAt ?? "forever"}");
  }
  ```

  ```cpp C++ theme={null}
  authforge::AuthForgeClient client("APP_ID", "", "PUBLIC_KEY");
  std::cout << "HWID: " << client.GetHwid() << "\n";

  if (client.LoginFromFile("license.authforge")) {
    const auto info = client.GetOfflineLicense();
    std::cout << "valid until " << (info->expiresAt ? *info->expiresAt : "forever") << "\n";
  }
  ```
</CodeGroup>

### Rejection codes

Checks run in this fixed order in every SDK; the first failure wins.

| Code                  | Meaning                                                                                                         |
| --------------------- | --------------------------------------------------------------------------------------------------------------- |
| `bad_armor`           | Not a well-formed `.authforge` file (truncated, wrong markers, not base64)                                      |
| `bad_signature`       | Signature does not verify against any configured public key: tampered file, or a file for a different app's key |
| `unsupported_version` | The payload `v` is newer than this SDK understands: upgrade the SDK                                             |
| `malformed_payload`   | Signed payload is missing required fields                                                                       |
| `wrong_app`           | Valid signature, but the file was minted for a different app id                                                 |
| `expired`             | Past the file's `expiresAt`                                                                                     |
| `hwid_mismatch`       | File is HWID-bound and this machine's HWID is not in the list                                                   |

`loginFromFile` reports these through `onFailure("offline_login_failed", error)` and returns false. Unlike `login()`, it never exits the process on its own: an air-gapped user deserves a message before the app closes.

<Note>
  Offline verification depends on the machine's clock for `expired`. A user can extend a file by winding the clock back, exactly like the grace period. Short expiries and HWID binding limit the blast radius; there is no server to consult.
</Note>

## File format (version 1)

The file is text, safe to email or copy through a data diode:

```text theme={null}
-----BEGIN AUTHFORGE LICENSE-----
Version: 1
App-Id: 3d3a...
License: XXXX-XXXX-XXXX-XXXX
Key-Id: 8c9e...
Expires-At: 2027-01-01T00:00:00.000Z

eyJ2IjoxLCJ0eXAiOiJhdXRoZm9yZ2UtbGljZW5zZSIsImFwcElkIjoiM2QzYS4u
...
-----END AUTHFORGE LICENSE-----
-----BEGIN AUTHFORGE SIGNATURE-----
OUi4frE6lPUXqvZDs4nHvbHGsR4ZKBis6lrJjnZfPbFinPtbVBrE/pwW9F6PHJsx
G+RbBBOIN+JN70CcDsOrAg==
-----END AUTHFORGE SIGNATURE-----
```

* **Headers are informational.** SDKs read every decision-relevant field from the signed payload, never from the headers.
* **Payload** (base64 JSON): `v` (1), `typ` (`authforge-license`), `appId`, `licenseKey`, `jti` (unique file id), `kid` (the app signing key id), `issuedAt`, `expiresAt` (ISO 8601 or `null`), `hwid` (`{ "mode": "bound", "hwids": [...] }` or `{ "mode": "any" }`), and optional snapshots taken at mint time: `label`, `licenseExpiresAt`, `licenseVariables`, `appVariables`.
* **Signed bytes:** the Ed25519 signature covers the UTF-8 bytes of the base64 payload *string* (body lines joined, all whitespace removed). This is the same contract as `/auth/validate` responses, which is why every SDK reuses its existing verify routine and why there is no JSON canonicalisation step.
* **Keys:** the file is signed with your app's existing per-app Ed25519 key (KMS-protected in AuthForge) and verified with the same public key you already embed in your app. Rotating the app key means new files carry the new `kid`; SDKs accept a list of public keys, so keep the old one in the list until every old file has expired.
* **Versioning:** SDKs reject any `v` other than 1 with `unsupported_version`. Future versions will be additive and announced with an SDK release.

Variable snapshots are frozen at mint time. If you change a license variable later, machines running on an existing file keep the old value until you mint and deliver a new file.

## Where this fits

* **Default:** online `login()` + grace period. Cheapest, revocable at the next activation, zero operator work.
* **High-value software with connectivity:** online `login()` + [online check-ins](/concepts#online-check-ins-optional) for fast revocation.
* **Machines that can never connect:** offline license files, short-lived and HWID-bound, re-issued on a schedule. See [Offline licensing best practices](/best-practices/offline-licensing).

Out of scope, by design: bring-your-own signing keys, client-side minting, floating seats, and any claim of remote revocation for files already distributed.
