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 examplemy-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¶
- Generate a new key:
new_key = generate_api_key(). - Store
user.api_key_hash = hash_password(new_key)and save. - Deliver the new key to the client over an out-of-band channel.
- 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
Userrow with a differentapi_codeand 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. |