Authentication

GeckoGuard uses different credentials for different operations. This page tells you exactly which credential each endpoint accepts so you can stop guessing.

Auth matrix

The single most important table in the docs. Pick the row that matches what you're doing:

OperationEndpoint prefixCredentialHow to send it
Validate a license at runtimePOST /v1/licenses/authorizeAPI key + signed requestX-Api-Key: gg_… plus X-GG-Timestamp, X-GG-Nonce, X-GG-Signature headers
Token introspection ("is my key valid?")GET /v1/whoamiAPI keyX-Api-Key: gg_…
Backend integration (recommended path): create/list/revoke licenses, manage blacklists, read analytics/v1/products/:productId/*API key with management scopesX-Api-Key: gg_…
List API keys for a productGET /v1/products/:id/api-keysAPI keyX-Api-Key: gg_…
Dashboard UI operations (alt path used by the web UI)/v1/dashboard/*JWT access token from /v1/auth/loginAuthorization: Bearer <accessToken>
Manage the current user (orgs, profile, active org)/v1/dashboard/me, /v1/dashboard/orgsJWT access tokenAuthorization: Bearer <accessToken>
Bootstrap the very first product / API keyPOST /v1/products, POST /v1/api-keysBootstrap admin tokenX-Admin-Token: <BOOTSTRAP_ADMIN_TOKEN>
End-user portal (your customers' accounts)/v1/enduser/*End-user JWTAuthorization: Bearer <endUserToken>
Status / health checksGET /health, GET /v1/statusnone (public)

For backend integrations (Discord bots, admin tools, CRMs), use /v1/products/:productId/* with an API key — the recommended path. API keys carry granular scopes (license:create, license:revoke, blacklist:write, etc.) and are long-lived. The dashboard offers a scope picker so you grant only what your bot needs.

API keys are NOT accepted on /v1/dashboard/*. Those endpoints are JWT-only (used by the GeckoGuard web UI). Sending an API key there returns 401 UNAUTHORIZED. They exist mainly for the dashboard itself; new backend integrations should target /v1/products/:productId/*.

X-Admin-Token is for one-time bootstrap only. It exists to break the chicken-and-egg of "you need a product to create an API key, but you need an API key to create a product." Use it once at install time and then never again. Ongoing automation must use an API key — not the bootstrap token. The endpoint also requires BOOTSTRAP_ENABLED=true on the server, which is normally off in production.

Backend integrations: API key with management scopes

This is the path you want for any server-side automation that mints, lists, revokes, or otherwise manages licenses on behalf of your customers.

Step 1 — issue an API key in the dashboard. Go to API Keys → Create, pick the product, and check the management scopes you need:

ScopeWhat it lets the key do
license:readList + read licenses (GET /v1/products/:productId/licenses, GET .../licenses/:id)
license:createCreate new licenses (POST .../licenses)
license:updateUpdate expiry, policy, metadata (PATCH .../licenses/:id)
license:deletePermanently delete a license (destructive — prefer revoke)
license:revoke / license:unrevokeToggle a license's REVOKED status
license:reset_hwid / license:reset_ipClear hardware-ID / IP bindings on a license
blacklist:read / blacklist:writeList / add / delete HWID & IP blacklist entries
end_user:readLook up license owners (org-wide via /v1/end-users, per-product via /v1/products/:productId/end-users) and include endUserId / endUserUsername / endUserEmail on license reads
analytics:readRead product-scoped license counts grouped by status

Step 2 — call the API directly with X-Api-Key. No login flow, no refresh loop, no 2FA dance:

curl -X POST https://api.geckoguard.net/v1/products/$PRODUCT_ID/licenses \
  -H "X-Api-Key: $GECKO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: bot-$(date +%s)" \
  -d '{"expirationMode":"afterActivation","expiresAfterDays":30}'

Each key is bound to one product. To work across multiple products, issue one key per product. Routes return 403 FORBIDDEN if the key's productId doesn't match the path's :productId. Routes return 403 PERMISSION_DENIED (with the missing-scope name in the error message) if the key lacks the required scope — re-issue or edit the key to grant it.

Step 3 — verify your key with /v1/whoami before writing code.

curl -H "X-Api-Key: $GECKO_API_KEY" https://api.geckoguard.net/v1/whoami
# → { "ok": true, "data": { "apiKeyId": "…", "productId": "…" } }

If this returns 200, your key is valid. The next call you should make is GET /v1/products/$PRODUCT_ID/licenses?pageSize=1 — confirms the key has license:read and is bound to the right product.

For the full list of management routes, see API Reference → Management endpoints.

Two credentials, two flows

1. JWT access token (management)

For everything under /v1/dashboard/*. Get one with email + password:

curl -X POST https://api.geckoguard.net/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"…"}'

Response (no 2FA):

{
  "ok": true,
  "data": {
    "user": { "id": "…", "email": "you@example.com", "emailVerified": true },
    "tokens": {
      "accessToken": "eyJhbGciOiJIUzI1NiIs…",
      "refreshToken": "eyJhbGciOiJIUzI1NiIs…"
    }
  }
}

Use accessToken in Authorization: Bearer … on every dashboard call.

Token lifecycle

TokenDefault TTLBehavior
Access15 minutesStateless JWT. Verified on every request.
Refresh30 daysDB-backed and revocable. Single-use — each /v1/auth/refresh call revokes the presented token and issues a new access+refresh pair (sliding rotation).

Sample refresh loop: refresh roughly every 10 minutes (well before the 15-min access TTL), or lazily when a request returns 401.

curl -X POST https://api.geckoguard.net/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"…"}'

POST /v1/auth/logout revokes the refresh token immediately and adds it to a Redis denylist for the remainder of its TTL — useful when a credential is suspected compromised. Access tokens are stateless and remain valid until they expire (worst case: 15 min after logout). To invalidate access tokens immediately you must rotate JWT_ACCESS_SECRET — contact support.

If the same refresh token is presented twice, the second call returns 401 UNAUTHORIZED (the first call revoked it). This catches token theft as long as the legitimate client is also actively rotating: whichever party loses the race gets logged out.

2FA-enabled accounts (interactive only)

If 2FA is enabled, /v1/auth/login returns a challenge instead:

{
  "ok": true,
  "data": {
    "requires2FA": true,
    "challengeToken": "…",
    "user": { "id": "…", "email": "…" }
  }
}

You must complete the challenge by calling POST /v1/auth/totp/verify-login with the challengeToken plus a current TOTP code (or a one-shot backup code) to receive real tokens.

Footgun for bots: there is no programmatic way to bypass 2FA. A backend integration that depends on /v1/auth/login will break the moment the user enables 2FA. Until service tokens ship, bots should authenticate as a dedicated service-account user with 2FA disabled — not the human's primary account.

2. API key (license validation)

API keys authenticate runtime calls from your application — primarily /v1/licenses/authorize. Each key is scoped to a single product and to a set of permissions.

Creating an API key

API keys are managed from the dashboard UI ("API Keys" → "Create API Key"). You must select a product and at least one permission — keys with no permissions are rejected at request time with 403 API_KEY_NO_PERMISSIONS. The available permissions:

PermissionDescription
license:authorizeValidate license keys via /v1/licenses/authorize
license:readRead license details
license:writeCreate and update licenses
license:deleteDelete licenses
product:readRead product details
product:writeUpdate product settings

The key string is shown once at creation. Copy it immediately.

Sending an API key

Use X-Api-Key (case-insensitive variants and Authorization: Bearer gg_… are also accepted, but standardize on X-Api-Key):

curl -H 'X-Api-Key: gg_live_…' https://api.geckoguard.net/v1/whoami

Token introspection: /v1/whoami

Before writing code against an unfamiliar key, confirm what it can do:

curl -H 'X-Api-Key: gg_live_…' https://api.geckoguard.net/v1/whoami

Response:

{
  "ok": true,
  "data": {
    "apiKeyId": "9f1c…",
    "productId": "abc-123-…"
  }
}

If the call returns 401 UNAUTHORIZED, the key is invalid, revoked, or you're sending it on the wrong header. If it returns 403 API_KEY_NO_PERMISSIONS, the key exists but has no scopes — re-issue it from the dashboard with at least one permission.

For JWT introspection (which user, which orgs, which roles), use GET /v1/dashboard/me with the access token in Authorization: Bearer ….

Signed requests for /v1/licenses/authorize

/v1/licenses/authorize is the only endpoint that requires HMAC signing in addition to an API key, because it's the brute-force target for stolen keys. Signing is enforced by default in production (SDK_SIGNING_REQUIRED=true). Unsigned requests are rejected with 401 SIGNATURE_REQUIRED.

You have two options:

Option A — use a GeckoGuard SDK. Every official SDK signs requests for you. If you're not building a CLI/Discord bot/internal tool yourself, this is the path.

Option B — sign requests yourself. The recipe below is the same one the SDKs implement.

Required headers

HeaderValue
X-GG-TimestampUnix seconds (current time). Must be within ±300 seconds of server time.
X-GG-NonceRandom string, 16–64 chars. Each nonce can be used once within a 10-minute window.
X-GG-SignatureHex-encoded HMAC-SHA256 over the canonical string below.

Canonical string

METHOD\n
PATH\n
TIMESTAMP\n
NONCE\n
BODY_SHA256_HEX
  • METHOD — uppercase HTTP method (POST)
  • PATH — the path the request was sent to, with query string stripped (e.g. /v1/licenses/authorize)
  • TIMESTAMP — the same value sent in X-GG-Timestamp
  • NONCE — the same value sent in X-GG-Nonce
  • BODY_SHA256_HEXsha256(rawRequestBody) as lowercase hex. For an empty body, hash an empty string.

Lines are joined with literal \n (LF, single byte). HMAC-SHA256 the canonical string with your API key's signing secret (or SDK_SIGNING_SECRET if your deployment uses a global secret). Hex-encode the result and put it in X-GG-Signature.

Reference implementation (Node.js)

import crypto from 'node:crypto';

function signRequest({ method, path, body, signingSecret }) {
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString('hex');
  const bodyRaw = body ? JSON.stringify(body) : '';
  const bodyHash = crypto.createHash('sha256').update(bodyRaw).digest('hex');
  const canonical = `${method.toUpperCase()}\n${path}\n${ts}\n${nonce}\n${bodyHash}`;
  const signature = crypto.createHmac('sha256', signingSecret).update(canonical).digest('hex');
  return {
    headers: {
      'X-GG-Timestamp': ts,
      'X-GG-Nonce': nonce,
      'X-GG-Signature': signature
    },
    body: bodyRaw
  };
}

const body = { productId: 'abc-123', licenseKey: 'GK-…' };
const { headers, body: raw } = signRequest({
  method: 'POST',
  path: '/v1/licenses/authorize',
  body,
  signingSecret: process.env.GECKO_SIGNING_SECRET
});

await fetch('https://api.geckoguard.net/v1/licenses/authorize', {
  method: 'POST',
  headers: {
    'X-Api-Key': process.env.GECKO_API_KEY,
    'Content-Type': 'application/json',
    ...headers
  },
  body: raw
});
CodeHTTPMeaning
SIGNATURE_REQUIRED401Signing is required and one of the three headers was missing
INVALID_SIGNATURE401Bad timestamp, bad nonce format, or signature didn't match
SIGNATURE_EXPIRED401X-GG-Timestamp was outside the ±300s skew window — clock drift
NONCE_REUSED401The nonce was already seen within the 10-minute window
SIGNING_NOT_CONFIGURED500Server-side: no signing secret configured. Contact support.
SIGNING_UNAVAILABLE503Server-side: nonce store is down. Retry shortly.

Rate limits

Limits are enforced as separate buckets: per-IP, per-API-key, and per-product. Which buckets apply depends on what credential you sent.

Credential on the requestBuckets enforced
API key (X-Api-Key)per-IP + per-key (plan-based) + per-product
JWT access token (Authorization: Bearer <jwt>) on /v1/dashboard/*per-IP only
/v1/licenses/authorizeall of the above + per-license-key
No credential (public endpoints)per-IP only

The default per-IP limit is 120 requests/minute (API_KEY_IP_LIMIT_PER_MIN, env-configurable per deployment). A bot batching dashboard operations from a single IP shares that bucket with everything else from that IP — pace at well below 120/min, or run from multiple egress IPs.

Per-key limits are plan-based. Free tier defaults to 60/min; higher tiers go up to unlimited. Check your plan in the dashboard.

Per-product ceiling defaults to 10,000/min and stops a single product from monopolizing capacity even across many keys. You shouldn't notice it unless you're operating at scale.

Headers on every response:

HeaderCounts
X-RateLimit-Limit-IP / X-RateLimit-Remaining-IPThis minute's per-IP budget
X-RateLimit-Limit-Key / X-RateLimit-Remaining-KeyThis minute's per-key budget (only when API key sent)
X-RateLimit-Limit-Product / X-RateLimit-Remaining-ProductThis minute's per-product budget
Retry-AfterSeconds to wait before retrying (only on 429)

A value of -1 means unlimited.

/v1/licenses/authorize adds a per-license-key limit of 20 requests/minute. Real clients re-authorize once per session, so this comfortably absorbs retries while making credential-stuffing infeasible against a single license.

429 responses use one of:

  • RATE_LIMITED — IP, key, or per-license limit hit
  • PRODUCT_RATE_LIMITED — per-product ceiling hit (rare; usually means another integration is hammering the same product)

Always honor Retry-After. Implement exponential backoff on top of it.

Idempotency

Mutating endpoints support Idempotency-Key. If the network drops mid-request, replay the call with the same key and you'll get the same response back instead of creating a duplicate.

curl -X POST https://api.geckoguard.net/v1/dashboard/orgs/$ORG_ID/licenses \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: create-license-2026-04-29-abc' \
  -d '{"productId":"abc-123","count":1}'

Rules:

  • Key must match [A-Za-z0-9_-]{8,128}
  • Cached for 24h, scoped per API key (or per user for JWT calls)
  • Replays return the original response with Idempotent-Replayed: true
  • Reusing the same key with a different body returns 409 IDEMPOTENCY_KEY_REUSE
  • Bad key format returns 400 BAD_IDEMPOTENCY_KEY

CSRF

/v1/dashboard/* enforces CSRF defense-in-depth, but server-to-server callers don't need to do anything special — any request with a Bearer JWT in the Authorization header is allowed through. The CSRF check only kicks in for cookie-authenticated browser requests, which use the Sec-Fetch-Site header or fall back to an Origin allow-list.

If your call returns 403 CSRF_BLOCKED, you almost certainly forgot the Authorization: Bearer header.

Security checklist

  1. Never commit API keys, signing secrets, or refresh tokens to version control. Use environment variables or a secret manager.
  2. Use minimal permissions. A license-validator key only needs license:authorize.
  3. Use different keys per environment. Dev / staging / prod should never share keys.
  4. Rotate suspected-compromised keys immediately in the dashboard. Rotation issues a new secret while keeping the same key ID.
  5. Verify with /v1/whoami after deploying, so a misconfigured key fails fast at startup, not on the first user.