# ArcticAuth - Complete Documentation

> **Last changed: 2026-08-30**
>
> One file covering the whole product: what ArcticAuth is, how a script reaches it,
> every client, every endpoint a customer can call, and the parts of the threat
> model that are honest rather than flattering.
>
> This file is written to be read by a person or handed to a model. It is
> regenerated whenever a feature is added or changed - see the changelog at the
> bottom for what moved and when.

- **Product:** ArcticAuth (successor to PandaAuth)
- **Dashboard:** https://arcticauth.com
- **API base:** `https://api.arcticauth.com/api`

---

## 1. What ArcticAuth is

ArcticAuth is a key system and secure script delivery service for Roblox, with a
C# client for desktop software.

It does three things:

1. **Manages access keys.** A key can bind to a hardware id on first validation
   and can carry an expiry. A service policy can also allow or deny Roblox places
   at validation time. Executor values are client-supplied telemetry, not an
   unforgeable identity signal.
2. **Monetizes access.** Users pass through ad checkpoints (Linkvertise,
   Rinku.pro, Lootlab, Work.ink) before a key is issued. The gateway pays the
   service owner.
3. **Delivers the script without shipping the file.** The readable source stays
   in the vault. What travels is an encrypted session copy, decrypted in memory
   inside the executor and never written to disk.

The unit of everything is a **service**: one script and everything guarding it.
One account owns many services.

### The shape of a delivery

```
User opens /getkey/<serviceId>
  → (optional) captcha
  → N ad checkpoints, each with a dwell floor and anti-bypass verdict
  → key issued; its policy can bind it to their machine on validation
  → user pastes the loadstring into their executor
  → loader validates the key
  → vault streams one encrypted session copy over a websocket
  → decrypted in memory, executed, gone at close
```

---

## 2. Accounts

### Registration

- Email, username, password (12 characters minimum - length is the only rule).
- **Vault PIN**: four digits, optional at sign-up, set later in Account Settings.
- **Invitation / referral code**: optional unless the deployment is running as a
  closed beta. When optional, a code that resolves credits whoever referred you;
  a code that does not resolve is ignored rather than refusing the sign-up.
- A captcha appears when the deployment has one switched on for registration.

New accounts open with a token grant and are sent through a four-step setup
wizard: **Service → Revenue → Configuration → Done**. Every step after the first
is skippable, and everything the wizard configures is reachable afterwards from
the service settings screen.

### Sign-in

Two doors to the same session:

- **Password**, with the email or the username as the identifier.
- **API token** (`Authorization: Bearer <token>`), from Account Settings.

"Remember this browser" issues a 30-day rotating token. A password change revokes
every remembered device.

### One account per device

A browser that opens an account is held to it for a week. Trying to register a
second account from the same browser inside that week is refused, and the refusal
says when the lock lifts.

Signing in is **never** refused by this rule. A browser that signs into an account
it did not open is recorded and counted, and the count shows up in the admin user
view as "linked accounts" - staff look at it and decide. That split is deliberate:
getting a registration wrong costs somebody a support message, getting a sign-in
wrong locks a real person out of an account they own.

What identifies a device:

- A random id in a signed HttpOnly cookie. Accurate, and cleared by clearing
  cookies.
- A hash of the stable request headers plus the caller's network prefix, as the
  fallback when the cookie is gone. Survives a private window; also collides
  between identical machines on one network, which is the known cost.

Either signal matching counts as the same device. None of this can see a *person* -
a second machine, a phone, or a friend's laptop all register freely. It stops an
afternoon of sign-ups from one browser, which is the behaviour it was written for.

Operators tune it under `DeviceLock` in configuration: `Enabled`, `LockDays`
(7 by default), and `MatchOnFingerprint` - turn the last one off if your users
share managed or identical hardware.

### The API token

- Shown **once**, in full, when minted. After that only its last few characters.
- It **is** the account: anything the dashboard can do to a service, the token
  can do. There is no read-only variant.
- Regenerating it revokes the previous one immediately.
- Minting and revealing both require the account password, not just a session.

### The vault PIN

Four digits, guarding the secure vault specifically.

- Set and removed with the account password.
- One correct entry unlocks the vault on **that session** for **12 hours**.
- Signing out re-locks it immediately.
- Five wrong attempts lock it for 15 minutes.
- An account with no PIN set is **not** locked out - the gate is opt-in. The
  vault screen prompts to set one instead.
- API tokens bypass it. A headless build script has nobody standing at a keypad,
  and the PIN defends against a borrowed browser session, which a token does not
  have.

### Tokens (the currency)

Spendable balance, used for vault uploads that need obfuscation. Every movement
is a ledger line with a reason - there is no way to set a balance directly.
Buyable with PayPal, or granted by staff.

### Inactivity

Accounts with no sign-in for 90 days are deleted automatically. Staff accounts
are never swept, and neither are accounts that still own a service (a service is
a live key system with third parties behind it). Both exemptions are
configurable per deployment.

---

## 3. Services

| Field | Meaning |
|---|---|
| **Name** | Display name. Dashboard only, never in a public URL. |
| **Identifier** | Human handle. Dashboard only. Case-insensitive, unique. |
| **Slug** | The opaque id in the delivery URL. Random, rotatable. |
| **Plan** | Per-service: Free, Ice, Frozen. Distinct from the account tier. |
| **Active** | False takes the service offline without deleting anything. |

### Plans

Priced per service, not per account: one account can run a Frozen service and a
Free one side by side.

| Plan | Price | |
|---|---|---|
| **Free** | $0 | Where every service starts, and where a lapsed plan lands. |
| **Ice** | $10 | The middle plan. Also what a migration grants free for a window. |
| **Frozen** | $60 | The top plan. |

Paid through PayPal from the service overview. It is a redirect checkout: the
dashboard sends you to PayPal's approval page and PayPal returns you to
`/user/plan/return`, which completes the payment and takes you back to the
service. PayPal's script never enters the dashboard bundle.

**A fixed window, not a subscription.** Nothing stores a payment method, so
nothing can charge you again without you coming back. A plan runs for 30 days and
then the service drops to Free until it is bought again. Buying while one is
still running **extends** it rather than replacing it, measured from the current
expiry; buying a different plan changes the tier and keeps the same arithmetic on
the window, with no proration in either direction.

Every order snapshots the price and the length it was quoted, so changing either
later never rewrites what somebody already agreed to pay. Completing a payment
and applying the plan happen in one transaction: a plan is never granted without
the payment that bought it, and a captured payment is never left without the plan
it was for.

Prices and the window live in `ServicePlans`. The PayPal credentials do not -
they are on the admin Integrations screen, encrypted, and with none saved the
picker says payments are not set up rather than offering a button that opens a
checkout to nowhere.

### Key format

Two shapes, applied to keys minted from now on. Keys already issued keep the name
they were issued under, because the string *is* the key.

- **Segmented** - `artic_XXXX-XXXX-XXXXX-XXXX`
- **Random** - `artic_xxxxxxxxxxxxxxxxxxxxxxxxxx`

**Key prefix** is configurable per service: letters and numbers, up to 24
characters. Leave it empty and keys are named `artic_`. Set it to `artichubz` and
they are named `artichubz_`. The underscore is added for you.

### Checkpoints

| Setting | Range | Meaning |
|---|---|---|
| Expiration | 12–8760 hours | How long an issued key lasts. |
| Checkpoints | 1–20 | Gateway visits before a key is issued. |
| Session time | 1–1440 minutes | How long a half-finished run stays resumable. |
| Bypass delay | 1–300 seconds | Minimum dwell per step. A script arrives faster than a person can - that is the signal. |

**Key extend** lets a user add time to a key they already hold: N more gateway
visits for M more hours, without issuing a new key.

**Gateways**: Linkvertise, Rinku.pro, Lootlab, Work.ink. A primary is required; a
secondary is optional and must differ. Credentials belong to the *slot*, not the
provider - switching Linkvertise for Lootlab on the primary slot drops the
Linkvertise token rather than leaving a credential behind that belongs to a
different service.

Changing gateway does **not** invalidate keys already in the wild.

### HWID reset cooldown

Off by default. With it off a user cannot move machine without the owner
unbinding the key by hand - the safe default for a product whose promise is one
key, one machine. On, free and premium keys get separate cooldowns.

### Premium keys

Sell keys directly instead of routing through a checkpoint.

- **PayPal** - client id (public) + secret (encrypted at rest).
- **Stripe** - publishable key (public) + secret + webhook signing secret.

The webhook signing secret is not optional in practice: without it a forged
callback can mint a key for free. The settings screen names each missing field
specifically ("Payment credentials missing on Premium key: PayPal secret key")
rather than saying "not configured".

### Anti-bypass

A self-hosted verification page in front of the checkpoint. Watches for skipped
ads, userscript tools and URL tampering, then logs, blocks, or both.

- **Logging** - record the verdict, fire the webhook. Never refuses a key on its
  own, which is what makes it safe to enable first.
- **Blocking** - refuse the key when a bypass is detected. Off by default:
  blocking before the logs have been read turns every false positive into a
  customer who cannot get their key.
- **Require provider proof** - treat a completion with no gateway proof as a
  bypass. Off by default, and it has to be: not every gateway stamps a proof on a
  real completion.

Verdicts can post to a Discord webhook, stored encrypted and validated as a
Discord URL on the way in.

### Analytics

An optional Google Analytics tag on the service's own key page. Held to
`googletagmanager.com` URLs, because the value becomes the `src` of a script tag
served to the owner's users.

---

## 4. The secure vault

The vault holds the readable source. It is encrypted at rest and leaves only as a
per-session copy to an authorised run.

### Uploading

Per script you choose:

- **Mode** - what the vault injects above your code.
- **Obfuscation** - none, or a Luraph pass (costs tokens).
- **Lockmode** - the extra non-Luraph security layer.

Two things cost a token: a Luraph pass, and a source that already carries
somebody else's loader. Both are decided server-side from the bytes themselves,
never from a flag the client sends. Anything paid for that does not happen is
refunded automatically.

### Delivery: two shapes

**Loader-based** - one loader URL fronts the whole vault. Same bytes for every
script and every service, so it caches; `_G.SlugID` picks which script is pulled.

```lua
_G.SlugID = "YOUR_SCRIPT_SLUG"
loadstring(game:HttpGet("https://api.arcticauth.com/api/v1/loader"))()
```

**Lite** - no loader and no key check, served straight from its own URL.

```lua
loadstring(game:HttpGet("https://api.arcticauth.com/api/v1/s/YOUR_SCRIPT_SLUG"))()
```

### The loader GUI

While a protected script is being fetched, decrypted and executed, the loader
draws a small progress panel anchored to the **right edge** of the screen: a dark
surface with a white progress bar, stating the stage it is on. It is deliberately
small and edge-anchored so it never sits over the game.

### Many clients on one address

Multi-instance rigs - dozens of Roblox clients on one machine behind one address
- are a supported case, and the throttling is built for them.

Budget is spent **per machine**, not per address. The loader and the injected SDK
declare the machine in an `X-Artic-Machine` header, and each declared machine gets
its own request budget, so forty clients relaunching no longer starve each other.
Two ceilings sit over that: a high per-address rate, and a cap on how many
distinct machines one address may present per hour - without the second, the
header would just be a way to mint unlimited budgets.

When a launch burst does hit a limit, nothing fails silently:

- The bootstrap loader route answers with **runnable Lua that waits and refetches
  itself**, jittered and bounded to six attempts. Previously a throttled fetch
  returned an empty body, `loadstring` returned nil, and the executor printed
  *"attempt to call a nil value"* against the user's own line - which said nothing
  about rate limits to anybody reading it.
- The handshake, pull, beat and report calls answer with
  `{"ok":false,"reason":"rate","retryAfter":N}` and a `Retry-After` header. The
  loader retries on the server's number with jitter, and retries transport faults
  ("Empty reply from server", "Connection was reset", failed TLS handshakes) the
  same way. A real refusal - bad key, unknown script, frozen delivery - is
  returned on the first try, because retrying a verdict is only a slower way to
  hear it again.
- The first heartbeat of each run is spread across its interval, so clients
  started together do not beat together forever.

Operators tune the numbers under `VaultThrottle` in configuration:
`DeliveryPermitsPerMachinePerMinute`, `DeliveryPermitsPerIpPerMinute`,
`LoaderPermitsPerIpPerMinute`, `MaxDistinctMachinesPerIpPerHour` and
`RetryAfterSeconds`. Raise the per-address and distinct-machine numbers for large
rigs; lower them if the platform serves one player per address.

### Trust list

Per-service allow and deny entries. One list, read by everything: the key page,
every validation library, and every vault script on the service. Ban somebody
once and they are banned everywhere.

An entry names **either a machine or a Roblox player**, never both. The two catch
different evasions and neither replaces the other: a machine entry survives an
alt account and falls to a spoofer, a player entry survives a spoofed machine and
falls to an alt. Somebody you want gone for good gets both.

A **Block on either subject wins**, and it wins across subjects - a leaker whose
machine is banned does not get back in by signing into a whitelisted account, and
one whose account is banned does not get in from a whitelisted machine. Anything
else would let the weaker of the two entries decide.

The rules, in the order they apply:

- A service with no live entries is unaffected. The list is opt-in.
- Once a service has any live entry, a run has to name itself - a machine, a
  player, or both. A ban escaped by leaving the identifiers out of the request is
  not a ban.
- A live **Block** entry refuses, whatever key the caller carries.
- **Allow-only** engages the moment the service has one live Allow entry: from
  then on an unlisted machine is refused. Below that it does not, so a list that
  only bans keeps letting everybody else in - the first thing anybody does with
  this feature is ban one leaker, and that must not lock out every paying user at
  the same time.
- A lapsed entry is inert in both directions. An expired Block stops refusing; an
  expired Allow stops admitting, and stops counting towards the rule above.

On the vault, the list is per-script opt-in: **Trust list** on a loader-based
script makes its delivery consult the service's lists before serving. It is
checked before the key gate, because a ban is not a fact about a key - a blocked
machine is refused whether the key it presents is good, expired, or absent. Lite
scripts are served direct and report no machine, so the toggle does not apply to
them.

### Users

Who has been executing this service's scripts, at
`/user/services/{id}/vault/users`. Behind the vault PIN, like the rest of the
vault.

| Column | |
|---|---|
| **Date** | When the vault handed the script over. |
| **Player** | Display name and Roblox account id, when the loader sent one. |
| **Hardware ID** | As the executor reported it. |
| **Game** | Experience name and place id, when the loader sent one. |
| **Executor** | What the executor called itself. Client-supplied and unverified. |
| **Script** | Which of your vault scripts was served. |
| **Region** | Two-letter country from the edge. The country and nothing finer. |
| **Action** | Blacklist the machine, or the player, in one click. |

One search box covers every column, so you can paste a hardware id, type a
username, or search a game without choosing a field first.

**It is a delivery log, and it is presented as one.** The vault knows when it
handed a script over and nothing after that: not whether the script ran, not for
how long, not whether it worked. A screen claiming otherwise would be inventing
the half nobody measured.

**Rows are deleted after two days.** Every row names a real person's machine,
account, location and play session, collected so you can answer "who ran my
script" - and a table that kept that indefinitely would be a profile nobody asked
for. The window is enforced by a sweeper rather than by intention, the screen
says the number out loud, and switching the log off clears what was already
collected rather than freezing it. Tunable under `VaultExecutionLog`; setting
`Enabled` to false stops the recording, not just the screen.

Player and game names are resolved from Roblox when a row is written and stored
alongside the ids, not looked up when the page is read. A later rename does not
rewrite what the log says was there, and a page of fifty rows is not fifty calls
to somebody else's API. When a name cannot be resolved the id is shown instead.

Blacklisting from here writes a Block entry on the trust list. It refuses runs on
any script with **Trust list** switched on, and on every key validation - scripts
with the toggle off are unaffected.

### Loader benchmark

`loadstring(game:HttpGet("https://api.arcticauth.com/api/v1/bench"))()`

Times what stands between pressing execute and the first line of your script:
fetch, compile, handshake, pull, decrypt, load. Then it runs the real loader
once and reports that total too, unadjusted.

Set `_G.SlugID` and `script_key` first, the same way you would for the loader
itself. The report is printed to the console and left at
`getgenv().ArticBenchResult`.

It is served unobfuscated on purpose. A benchmark nobody can read is a marketing
number, and the people who wrote this also wrote the thing it measures - so the
method has to be checkable by anyone who doubts the result.

What it refuses to do:

- **No trimming.** Every sample is printed, and min/median/mean/max are all
  shown, so a good mean cannot hide a bad tail.
- **The first fetch is reported apart from the rest.** The bootstrap is cached
  server-side, so a cold cache pays for an obfuscation job that later fetches do
  not. Averaging them together would report a warm number as if it were what a
  first-time user gets.
- **A throttled sample stays throttled.** It is recorded and excluded, never
  retried into a successful timing.
- **One exec per session.** A second run has globals published, the key cached
  and the transport warm. It refuses by default; rejoin for a clean number.
- **No cross-product comparison.** It measures ArcticAuth and nothing else. A
  number taken here next to one taken on another key system's server, network and
  script would not be a comparison.

To compare Lockdown on against Lockdown off, run it once in each state. The tool
reads the hardened flag off the payload that actually arrived rather than off
what you told it to expect, so the two reports cannot be mislabelled.

Not measured, and none of it ours: your network round trip, your executor's Lua
speed, and Roblox's own HttpGet overhead. On a healthy connection those are most
of the total.

### Global lockdown

A platform-wide panic switch. Forces the non-Luraph security layer on for every
loader-based script at once, without editing any of them. Flip it when a
decompiler starts circulating; clear it once the obfuscator is patched.
Per-script Lockmode choices are left alone either way.

---

## 5. Keys

| State | Meaning |
|---|---|
| **Generated** | Minted, never used. |
| **Active** | Redeemed and bound. |

Keys can be generated in bulk, imported from a list, or issued automatically at
the end of a checkpoint run. A whitelist skips the checkpoints entirely.

**Compensation** hands time or keys back to affected users after an outage.

---

## 6. Clients

### Roblox Lua

The library is served from `/api/library` and versioned. Pin its digest from
`/api/library/manifest`.

In SDK mode the service id is baked in, so `configure()` is never called. The SDK
exposes: `service`, `slug`, `hwid`, `premium`, `expiresAt`, `validate(key)`.

In manual mode nothing is injected and nothing is delivered - your script stays
where it already lives and ArcticAuth only validates the key.

### C# / .NET

One assembly, no native dependencies. Multi-targets `netstandard2.0` (covers .NET
Framework 4.6.1+, Unity, Mono) and `net8.0`.

Worth being blunt about: a desktop binary is a far weaker position than a Roblox
vault script. The whole program is already on the attacker's disk, so a licence
check there is a lock on a door that is already open.

### HTTP API

Everything the dashboard itself calls. There is no second, lesser API kept in
step by hand.

```
Authorization: Bearer YOUR_ACCOUNT_TOKEN
```

| Area | Endpoints |
|---|---|
| Auth | `POST /v1/auth/register`, `/login`, `/token`, `/logout`, `/resume`, `GET /v1/auth/me` |
| Account | `GET/PUT /v1/account`, `/profile`, `/password`, `/api-token`, `/vault-pin`, `/tokens` |
| Services | `GET/POST /v1/services`, `GET/PUT /v1/services/{id}/settings` |
| Keys | `/v1/services/{id}/keys`, `/whitelist`, `/compensation` |
| Vault | `/v1/services/{id}/vault/scripts`, `/overview` |
| Trust list | `/v1/services/{id}/trust-list` |
| Validation | `/api/validation/v1/validate` |
| Checkpoints | `/v1/public/checkpoint/{serviceRef}/...` |
| System | `GET /v1/system/features`, `/ping` |

Anywhere a service is named, both its id and its slug are accepted. The slug is
the one to hand out: random rather than a timestamped uuid, and rotatable when a
loadstring leaks.

---

## 7. Migrating from PandaAuth

ArcticAuth is the successor to PandaAuth, so bringing an account across is a
first-class flow rather than an export and a paste. It is a **copy**: PandaAuth
is left exactly as it was unless you ask, on the last screen, for the migrated
content to be cleared - and that offer only appears once the copy has actually
landed.

Start it from **Dashboard -> Import -> PandaAuth**, or go straight to
`/migrate/pandaauth`.

### Connecting

ArcticAuth never sees your PandaAuth password.

1. The wizard opens PandaAuth in a **new tab**. You sign in there as you normally
   would - captcha, two-factor and email verification all happen on their site.
2. PandaAuth shows a consent screen naming what is being asked for and how much
   is on the account. It shows counts, not contents: opening the screen exposes
   no key and no script.
3. Approving sends the tab back to ArcticAuth with a **single-use code**. The code
   is worthless on its own: ArcticAuth's server trades it using a PKCE verifier
   that never leaves the server and a secret shared between the two installs.
4. What comes back is a grant to read that account, held server-side for
   **45 minutes** and then gone. It is never written to the database.

**Disconnect** hands the grant back immediately.

### What one migration moves

One of the two, not both - they land in different places and are counted
separately:

| | Source | Lands in |
|---|---|---|
| **Key Management** | One PandaAuth service | One ArcticAuth service |
| **Kryptic Vault** | Up to **five** scripts | One service's vault |

The vault is per-account on PandaAuth and per-service here, so a vault migration
asks which service the scripts should land in. Either kind can create a new
service or fill one you already have.

**One migration per account at a time**, and the next one opens **15 days** after
the last completed run. That limit is about load on PandaAuth - a migration walks
a whole key table and reads whole script bodies out of their database - rather
than about capacity here. A run you abandon costs nothing: only a completed run
starts the clock.

### Keys

Every key on the source service comes across, claimed and unclaimed alike, with
its machine binding, note, Discord id, premium flag, expiry and revocation.
PandaAuth keeps claimed and unclaimed keys in two tables and deletes from one as
it writes to the other; ArcticAuth keeps both states in one table and tells them
apart by whether the key has been activated, so the distinction survives the
move.

Two things are read carefully rather than copied verbatim, because copying them
would be wrong:

- **Lifetime** is a sentinel date on PandaAuth, not a null. It arrives here as a
  genuine lifetime key.
- **Duration-on-first-use** keys have a placeholder date and a day count. There is
  no equivalent here, so the expiry is computed from the day count at the moment
  of the migration. The clock starts at the move rather than at first launch.

What does not come across, because there is nothing here to hold it:
monetisation providers beyond the four ArcticAuth supports, per-key device and
account limits, contributor permissions, and multi-machine or multi-account key
state. A **suspended** key arrives revoked - of the two available answers, "the
owner had stopped this key" is closer to the truth than "this key works".

Keys are matched per service, so a value already on the target service is skipped
and reported rather than overwriting anything. A value longer than 128 characters
does not fit the column and is skipped with a reason.

### Kryptic Vault

Each selected script brings its **obfuscated build**, its **raw source** and the
**configuration it was built with**. The configuration is stored whole, in
PandaAuth's own shape, rather than mapped onto fields - it describes a build
pipeline this vault does not run, and picking out the three settings that happen
to line up would be a migration that quietly lost the rest.

- **Backup history does not migrate.** Only the current build of each script
  comes across. Past versions stay on PandaAuth: they are snapshots of a pipeline
  this vault cannot re-run, so restoring one here would produce a script nothing
  on this side could rebuild. The wizard says how many versions are being left
  behind before you pick.
- **Obfuscator**: a script built with Luraph is recorded as Luraph and can be
  re-hardened here with this vault's own settings. Anything else - Moonveil,
  wYnFuscator, Panda-K, a custom engine - is recorded as **Unknown Obfuscator**.
  The body is served exactly as it arrived; what is unavailable is re-hardening,
  because there is no pass to re-run.
- Migrated scripts arrive **disabled** and on a **new delivery URL**. Any
  loadstring already handed out keeps pointing at PandaAuth until you replace it,
  and nothing is served here until you enable it.
- They arrive in **lite mode**, shipped as-is with no loader gate chain wrapped
  round them: a Kryptic script already carries whatever gating PandaAuth built
  into it, and wrapping this vault's loader round it would produce a script
  running two key systems and satisfying neither.

### Clearing the source

After the copy finishes - never before - the wizard offers to clear what was
migrated from PandaAuth. Declining is a perfectly good answer; the two systems
run side by side.

For a vault migration this deletes the scripts you just copied, and their backup
history with them.

For a key migration, be aware that it removes **every key on that PandaAuth
service**, not only the ones copied - PandaAuth's purge takes a service rather
than a list, so a key created there after the migration started would go with the
rest. Deleting the whole service is offered separately.

### Running it

There is no background queue. The wizard pulls one batch at a time and redraws
from counters the server keeps, so the progress bar is a real count over a real
total rather than an estimate. If a batch fails, everything already copied is
kept and the run can be retried or abandoned; closing the tab leaves the run
where it was, and reopening the page picks it back up.

### Configuration

| Setting | Where | Notes |
|---|---|---|
| `PandaMigration:Enabled` | Configuration | Off by default. The whole surface answers 503 while it is off. |
| `PandaMigration:ApiBaseUrl`, `AuthorizeUrl` | Configuration | Pinned. Never taken from a request. |
| `PandaMigration:CooldownDays` | Configuration | Days between completed migrations. |
| `PandaMigration:MaxVaultScripts` | Configuration | Scripts per vault migration. |
| Shared secret | **Admin -> Integrations** | Encrypted at rest. Must match `ARTICAUTH_MIGRATION_SECRET` on the PandaAuth server. Both ends refuse without it. |

---

## 8. Platform integrity

### Captcha (hCaptcha)

Switched on per surface from the admin Integrations screen:

- **Key page** - every `/getkey` visitor solves one before a run starts. This is
  the door bots actually knock on.
- **Registration** - stops scripted signups farming the welcome grant.
- **Sign-in** - slows credential stuffing, at the cost of friction for every
  returning customer.

Two rules hold it together. A surface is gated only when its switch is on **and**
both halves of the hCaptcha key pair are present - a switch demanding a token
nobody can produce is an outage, not a defence. And a verification that cannot
reach hCaptcha **fails open**, because the alternative is one provider outage
closing registration, sign-in and every key page at once. While it is down, the
rate limits are what remain.

### Rate limits

A tight limit on every authentication door (register, login, token login, password
reset, PIN unlock, and every password-confirmed write). A separate limit on the
checkpoint chain. A global limit keyed by account, falling back to address.

### Secrets at rest

Provider credentials, gateway API keys, payment secrets, webhook URLs and API
tokens are all encrypted with a Data Protection key ring held in Redis. A database
dump is a set of ciphertexts; the keys that open them live under a separate
credential.

**Operationally: the Redis volume is backup-critical.** Lose the key ring and
every stored secret has to be re-entered.

### Credentials live in the admin dashboard, not in `.env`

Provider credentials are edited at `/admin/integrations` by signing in as an
admin. A value saved there beats the environment, so a credential can be rotated
without editing a file on the VPS and redeploying. Clearing it falls back to the
environment, so nothing is trapped in the database either.

Deliberately absent from that screen: connection strings, the JWT signing key and
the ports. Those must be readable before there is a database to read them from.

---

## 9. Threat model, honestly

Three things this product does not claim:

1. **The executor sees everything sent to it.** It has full read access to the
   delivered payload. Every delivery is watermarked instead, so a leaked copy
   names the account that pulled it and that account can be revoked.
2. **A determined person can reverse the loader.** It runs on their machine.
   Builds rotate, so a published bypass has a short shelf life rather than none.
3. **Traffic is readable to whoever runs the client.** That is what a debugging
   proxy is for, and it is not a flaw. The exchange is built so a captured
   request is worth nothing when replayed.

What the product *does* hold: the readable source never lands on disk, a key that
has been passed around stops at the gate rather than after the damage, and
revocation takes effect on the next validation with no redeploy.

---

## 10. Changelog

Entries are newest first. Every feature change updates this file and the date at
the top of it.

### 2026-08-30

- **Documentation hub and discovery index.** Documentation now has a public
  `/docs` entry point, complete crawlable HTML references, and an `llms.txt`
  index that points people and AI search tools at the canonical source. The
  searchable documentation and the raw Markdown are both listed in the sitemap.
- **Canonical API examples.** Public loader and API examples now consistently
  use `https://api.arcticauth.com`, and the key lifecycle description separates
  optional machine binding and place policy from executor telemetry.

### 2026-08-25

- **Import from Junkie Key System (Beta).** Pulls a service's keys through
  Junkie's v2 REST API - key value, HWID (first of possibly several; the rest
  land in the note), Discord ID, expiry, invalidated state - into a new
  ArcticAuth service. The project id field is Junkie's numeric `service_id`.
  Paginated at the API's own 100-per-page ceiling. Carries the same 15-day
  Ice grant as a Luarmor import. Marked Beta: Junkie has no HWID-reset
  timestamp to carry across, so a migrated key's self-serve reset cooldown
  starts fresh rather than picking up where the source left off.

### 2026-08-22

- **Migration from PandaAuth.** A consent handshake and a wizard that copies
  either one PandaAuth service's keys or up to five Kryptic Vault scripts into an
  ArcticAuth service. Sign-in happens on PandaAuth in a new tab; ArcticAuth only
  ever receives a single-use code, traded server-side against a PKCE verifier and
  a shared secret for a grant that lives 45 minutes and is never stored. One
  migration per account at a time, 15 days between completed runs, and the source
  is left untouched unless the owner asks at the end. See section 7.
- **Unknown Obfuscator.** The vault can now record a script whose obfuscator this
  product does not run - Moonveil, wYnFuscator and the rest arrive from a
  migration with their build served as-is and re-hardening unavailable, rather
  than being recorded under a provider nothing here can reproduce.
- **Open registration.** The invitation code is optional for everyone by default:
  a code that resolves is kept as the referral that brought the account in, one
  that does not is ignored rather than refusing the sign-up. Deployments can
  close the door again with `Registration:InvitationOnly`.
- **Import switched on.** Provider imports and the migration promo that rides on
  them default to enabled rather than answering 503.
- **Plans are buyable.** The per-service plan picker now starts a real PayPal
  order instead of reporting that the checkout was not wired. A redirect
  checkout, a fixed 30-day window rather than a subscription, and buying again
  extends rather than replaces. See section 3.
- **The vault reads the trust list.** The per-script Trust list toggle was stored
  and never consulted at delivery, so a blocked machine still got the script
  unless the script happened to take a `script_key`. It is now checked on every
  loader pull that asks for it, ahead of the key gate. See section 4.
- **One crown for the staff view.** The admin header had a mode pill beside a
  separate Admin link, which narrated a state nobody needed narrated and took two
  presses to get anywhere. It is now a single crown: press it for the admin
  screens, press it again to come back to your dashboard as an ordinary account
  sees it. Filled while the admin screens are showing. It also survives on a
  phone, where the labelled pill was hidden.
- **Vault Users screen.** Who has been executing your scripts: date, player,
  machine, game, executor, script, region, and a one-click blacklist, with one
  search box over all of it. A delivery log rather than a session list, and rows
  are deleted after two days. See section 4.
- **Trust list entries can name a player.** Previously a machine only, which fell
  to anyone willing to spoof one. An entry now names a machine or a Roblox
  account, a Block on either wins, and the loader checks both. See section 4.
- **A public changelog.** This list, as a page at `/changelog` rather than only
  as a section of a file somebody has to download first.

### 2026-08-18

- **Simultaneous runs on one address.** Vault throttling is now budgeted per
  declared machine rather than per address, with a per-address ceiling and an
  hourly cap on distinct machines over it. Throttled callers get an answer they
  can parse instead of an empty body: runnable retry Lua on the loader route,
  `{ok:false,reason:"rate",retryAfter}` on the JSON routes, `Retry-After` on both.
  The loader retries throttles and transport faults with jittered backoff, and
  heartbeats are spread across their interval. Tunable under `VaultThrottle`.
- **One account per device.** A browser that registers an account is held to it
  for a week; a second registration inside that window is refused with the date
  the lock lifts. Sign-ins are never refused - a browser signing into an account
  it did not open is counted and surfaced in the admin user view as "linked
  accounts", with a per-account device report behind it. Tunable under
  `DeviceLock`.
- **Vault PIN.** Four-digit gate in front of every service vault. Set at sign-up
  or in Account Settings, password-confirmed both ways. One unlock lasts 12 hours
  per session, dies at sign-out, and locks out for 15 minutes after five wrong
  attempts. Enforced server-side on every vault endpoint, not just in the UI.
- **hCaptcha.** Site key and secret key on the admin Integrations screen, with
  independent switches for the key page, registration and sign-in under a new
  **Integrity** panel. Fails open when hCaptcha is unreachable.
- **Configurable key prefix.** Per service, letters and numbers, defaulting to
  `artic_`. Applies to keys minted from then on.
- **First-run setup wizard.** Service → Revenue → Configuration → Done, ending
  with how to implement the loadstring. Skippable at every step.
- **Invitation code is now optional** unless the deployment is invitation-only.
  When optional it acts as a referral code, and an unusable one is ignored rather
  than refusing the sign-up.
- **Specific payment-credential errors.** The settings screen names the missing
  field ("Payment credentials missing on Premium key: PayPal secret key") instead
  of reporting a generic misconfiguration.
- **Admin: reset tokens.** One-click return of a balance to the welcome grant,
  hidden above 10 tokens where some of the balance may have been bought.
- **Admin: role and status filters** on the users screen, alongside the existing
  search.
- **Automatic removal of dormant accounts** after 90 days with no sign-in. Staff
  and service owners are exempt by default.
- **Rotating homepage copy.** Twelve variants, one drawn per page load.
- **Rotating dashboard message.** Fifteen variants replacing the fixed "role on
  the tier" line.
- **Documentation:** renamed to **ArcticAuth Documentation**, made public and
  prerendered, given in-page word search with match highlighting, Lua/C#/shell
  syntax colouring in every code block, and this downloadable Markdown file.
- **Inbox filtering.** Messages advertising rival key systems are hidden by
  default, with a count and a way to show them. Nothing is deleted.
- **Layout fixes.** The admin Integrations screen now uses the right-hand column
  for the Integrity panel instead of leaving it empty; the service settings
  panels scroll inside their own bounds when Key extend and HWID reset cooldown
  are both open, rather than dragging the grid's hairline out of line.
