Skip to content

API Keys

API keys are Craft Easy's machine-to-machine authentication. Use them for scheduled jobs, partner integrations, internal services and anything else that cannot — or should not — sit in front of a browser and receive an OTP.

A user row in Craft Easy can carry both an interactive profile and an API key. The key is represented by two fields:

  • api_code — a short, human-visible identifier (for example my-batch-service).
  • api_key — the secret, stored as a bcrypt hash. Only shown once, at creation.

Creating a key

from craft_easy.core.auth import generate_api_key
from craft_easy.core.auth.password import hash_password
from craft_easy.models.user import User

api_key_plain = generate_api_key()       # 256+ bit URL-safe string
api_key_hash  = hash_password(api_key_plain)  # bcrypt

user = await User(
    name="Batch Worker",
    email="batch@example.com",
    api_code="my-batch-service",
    api_key_hash=api_key_hash,
    system_user=True,
).insert()

# Hand the plaintext key over to the operator ONCE — it cannot be recovered.
print("api_code:", user.api_code)
print("api_key :", api_key_plain)

generate_api_key() uses secrets.token_urlsafe(48) which produces at least 256 bits of entropy.

Authenticating with a key

Clients POST to /auth/api-key with the code/key pair. The response is a regular TokenResponse — once you have the JWT, the rest of the API behaves identically to any other login.

import httpx

resp = httpx.post(
    "http://localhost:8000/auth/api-key",
    json={
        "api_code": "my-batch-service",
        "api_key": "Zm9vYmFyQmF6cXV1eA...",
    },
)
resp.raise_for_status()
token = resp.json()["token"]

client = httpx.Client(
    base_url="http://localhost:8000",
    headers={"X-API-Key": token},
)
curl -X POST 'http://localhost:8000/auth/api-key' \
  -H 'Content-Type: application/json' \
  -d '{"api_code":"my-batch-service","api_key":"Zm9vYmFyQmF6cXV1eA..."}'

The resulting token has token_type=service, is_system_user=true and its extra.auth_method claim is "api_key". Tokens expire according to JWT_USER_EXPIRY_MINUTES (default 30 minutes); re-authenticate when the token nears expiry — service tokens are not refreshable via /auth/refresh.

Signed-JWT authentication (public key)

For stronger, non-replayable authentication, a client can sign a short-lived JWT with its own private key instead of sending the plaintext API key on every request. The public keys are stored on the user under api_code_public_keys.

import jwt, time
private_key = open("client-private.pem").read()

signed = jwt.encode(
    {
        "api_code": "my-batch-service",
        "iat": int(time.time()),
        "exp": int(time.time()) + 60,
    },
    private_key,
    algorithm="ES512",
)

resp = httpx.post(
    "http://localhost:8000/auth/api-key/signed",
    json={"token": signed},
)

The server tries each registered public key with ES512 and then RS256. The first match wins. Rotate keys by adding the new one, waiting for clients to switch, then removing the old one.

IP whitelisting

Every API code can carry a list of allowed IPs and CIDR blocks in user.api_code_ips. Requests from outside that list are rejected with 403 IP address not allowed.

from craft_easy.models.user import IpAllowlistEntry

user.api_code_ips = [
    IpAllowlistEntry(ip="203.0.113.42"),
    IpAllowlistEntry(ip="10.0.0.0/8"),
]
await user.save()

An empty list means "no restriction". The underlying helper check_ip_whitelist(request_ip, allowed_ips) (in craft_easy.core.auth.api_key) supports exact IPv4/IPv6 addresses and CIDR notation.

Rate limiting & lockout

API-key authentication has its own rate-limit layer on top of the global auth limits. It is per api_code, tracked in-process.

Setting Default Meaning
API_KEY_AUTH_MAX_PER_MINUTE 10 Sliding-window cap — attempts per minute per api_code.
API_KEY_AUTH_LOCKOUT_THRESHOLD 5 Failed attempts within the lockout window before the code is locked.
API_KEY_AUTH_LOCKOUT_MINUTES 15 Lockout duration once the threshold is reached.

On the wire:

Error class HTTP status Client action
ApiKeyInvalidError 401 Check code and key.
ApiKeyDisabledError 403 User account is disabled — contact admin.
ApiKeyIPDeniedError 403 Caller IP not in allow-list.
ApiKeyRateLimitError 429 with Retry-After: 60 Slow down; wait 60s.
ApiKeyLockedOutError 429 with Retry-After: <seconds> Wait and re-attempt after the lockout expires.

Key rotation

  1. Generate a new key: new_key = generate_api_key().
  2. Store user.api_key_hash = hash_password(new_key) and save.
  3. Deliver the new key to the client over an out-of-band channel.
  4. Once the client has switched over, the old key is already unusable — there is no overlap because the hash is overwritten. For zero-downtime rotation, create a second User row with a different api_code and migrate callers before deleting the old one.

Endpoints reference

Method Path Purpose
POST /auth/api-key Authenticate with api_code + api_key.
POST /auth/api-key/signed Authenticate with a client-signed JWT.