Loading repository data…
Loading repository data…
Athroniaeth / repository
keyshield (old repo name was fastapi-api-key) provides a backend-agnostic library that provides a production-ready, secure API key system, with optional FastAPI, Litestar, Django, Quart and Typer connectors.
keyshield provides a backend-agnostic library that provides a production-ready, secure API key system, with optional connectors for FastAPI, Litestar, Quart, Django, and Typer CLI.
This library try to follow best practices and relevant RFCs for API key management and authentication:
Authorization: Bearer <api_key> header for
key transmission (also supports deprecated X-API-Key header and api_key query param)401 Invalid, regardless of whether the key is inactive or expired.HMAC-SHA256(pepper, secret) before passing it to bcrypt, producing a fixed
32-byte digest that eliminates bcrypt's silent 72-byte input truncation.^[a-z][a-z0-9:_\-]*$) at the schema level. Note: RFC 6749 §3.3
permits a broader character set; this restriction is a deliberate keyshield design choice to
prevent injection of arbitrary characters (HTML, SQL fragments, etc.) in scope values.ak_v1, ak_v2, …) so a future algorithm or structure migration
can be detected at parse time — old keys keep their prefix and old keys always fail with 401
rather than silently producing wrong hashes. Custom prefixes are still fully supported.This project is published to PyPI. Use a tool like uv to manage dependencies.
uv add keyshield
uv pip install keyshield
Clone or fork the repository and install the project with the extras that fit your stack. Examples below use uv:
uv sync --extra all # fastapi + sqlalchemy + argon2 + bcrypt
uv pip install -e ".[all]"
For lighter setups you can choose individual extras:
| Installation mode | Command | Description |
|---|---|---|
| Base installation | keyshield | Installs the core package without any optional dependencies. |
| With Bcrypt support | keyshield[bcrypt] | Adds support for password hashing using bcrypt |
| With Argon2 support | keyshield[argon2] | Adds support for password hashing using Argon2 |
| With SQLAlchemy support | keyshield[sqlalchemy] | Adds database integration via SQLAlchemy |
| With Cache Service support | keyshield[aiocache] | Adds database integration via Aiocache |
| Core setup | keyshield[core] | Installs the core dependencies (SQLAlchemy + Argon2 + bcrypt + aiocache |
| FastAPI only | keyshield[fastapi] | Installs FastAPI as an optional dependency |
| Full installation | keyshield[all] | Installs all optional dependencies |
uv add keyshield[sqlalchemy]
uv pip install keyshield[sqlalchemy]
uv sync --extra sqlalchemy
uv pip install -e ".[sqlalchemy]"
Development dependencies (pytest, ruff, etc.) are available under the dev group:
uv sync --extra dev
uv pip install -e ".[dev]"
Run the full lint suite with the provided Makefile:
make lint
Install make via sudo apt install make on Debian/Ubuntu or choco install make (Git for Windows also ships one) on Windows, then run the command from the project root to trigger Ruff, Ty, Pyrefly, and Bandit through uv run.
import asyncio
from keyshield import ApiKeyService
from keyshield.repositories.in_memory import InMemoryApiKeyRepository
async def main():
repo = InMemoryApiKeyRepository()
service = ApiKeyService(repo=repo) # default hasher is Argon2 with a default pepper (to be changed in prod)
entity, api_key = await service.create(name="docs")
print("Give this secret to the client:", api_key)
verified = await service.verify_key(api_key)
print("Verified key belongs to:", verified.id_)
asyncio.run(main())
Override the default pepper in production:
import os
from keyshield import ApiKeyService
from keyshield.hasher.argon2 import Argon2ApiKeyHasher
from keyshield.repositories.in_memory import InMemoryApiKeyRepository
pepper = os.environ["SECRET_PEPPER"]
hasher = Argon2ApiKeyHasher(pepper=pepper)
repo = InMemoryApiKeyRepository()
service = ApiKeyService(
repo=repo,
hasher=hasher,
)
This is a classic API key if you don't modify the service behavior:
Structure:
{global_prefix}-{separator}-{key_id}-{separator}-{key_secret}
Example:
ak_v1-7a74caa323a5410d-mAfP3l6yAxqFz0FV2LOhu2tPCqL66lQnj3Ubd08w9RyE4rV4skUcpiUVIfsKEbzw
ak_v1 (for "Api Key v1"), to identify both the key type and the format version — allowing future algorithm migrations without breaking existing keys (e.g. ak_v2-… for a future format).When verifying an API key, the service extracts the identifier, retrieves the corresponding record from the repository, and compares the hashed secret. If found, it hashes the provided secret (with the same salt and pepper) and compares it to the stored hash. If they match, the key is valid.
Here is a diagram showing what happens after you initialize your API key service with a global prefix and delimiter when you provide an API key to the .verify_key() method.
---
title: "keyshield — verify_key() Flow"
---
flowchart LR
%% ── Styles ──────────────────────────────────────────────
classDef startNode fill:#90CAF9,stroke:#1565C0,color:#000
classDef processNode fill:#FFF9C4,stroke:#F9A825,color:#000
classDef rejectNode fill:#EF9A9A,stroke:#C62828,color:#000
classDef acceptNode fill:#A5D6A7,stroke:#2E7D32,color:#000
classDef cacheNode fill:#90CAF9,stroke:#1565C0,color:#000
classDef noteStyle fill:#FFFDE7,stroke:#FBC02D,color:#555,font-size:11px
%% ── Entry ───────────────────────────────────────────────
INPUT(["`**Api Key**
_ak_v1-7a74…10d-mAfP…bzw_`"]):::startNode
%% ── Main flow ───────────────────────────────────────────
CACHED{"`**Is cached key?**
_(hash api key to SHA-256
to avoid Argon slow hashing)_`"}:::processNode
NULL_CHECK{"`**Is null or empty
string value?**`"}:::processNode
SPLIT["`**Split string**
_(by global_prefix)_`"]:::processNode
THREE_PARTS{"`**Has strictly
3 parts?**`"}:::processNode
PREFIX_CHECK{"`**First part equals
to global prefix?**`"}:::processNode
QUERY_DB["`**Query API Key
by key_id**`"]:::processNode
COMPARE{"`**Compare db api key hash
to received api key hash**`"}:::processNode
STATE_CHECK{"`**Check state & scopes**
_(active? not expired?
required scopes?)_`"}:::processNode
%% ── Outcomes ────────────────────────────────────────────
REJECT(["`🔴 **Reject API Key**`"]):::rejectNode
ACCEPT(["`🟢 **Accept API Key**`"]):::acceptNode
CACHE_STORE(["`🔵 **Cache API Key**`"]):::cacheNode
%% ── Happy path (left → right) ──────────────────────────
INPUT --> CACHED
CACHED -- "no" --> NULL_CHECK
NULL_CHECK -- "no" --> SPLIT
SPLIT -- "exec" --> THREE_PARTS
THREE_PARTS -- "yes" --> PREFIX_CHECK
PREFIX_CHECK -- "yes" --> QUERY_DB
QUERY_DB -- "found" --> COMPARE
%% ── Accept path ─────────────────────────────────────────
CACHED -- "yes" --> ACCEPT
COMPARE -- "equals" --> STATE_CHECK
STATE_CHECK -- "valid" --> ACCEPT
ACCEPT -- "`**APIKey.touch()**
_(update last_used_at)_`" --> CACHE_STORE
%% ── Reject paths ────────────────────────────────────────
NULL_CHECK -- "yes" --> REJECT
SPLIT -- "Exception" --> REJECT
THREE_PARTS -- "no" --> REJECT
PREFIX_CHECK -- "no" --> REJECT
QUERY_DB -- "not found" --> REJECT
COMPARE -- "not equals" --> REJECT
STATE_CHECK -- "invalid (403)" --> REJECT
%% ── Notes (annotations) ─────────────────────────────────
NOTE_FORMAT["`**Format API Key**
{global_prefix}: str
{separator}: str
{key_prefix}: UUID
{separator}: str
{key_secret}: UUID
_global_prefix = 'ak_v1'_
_separator = '-'_`"]:::noteStyle
NOTE_CACHE["`**Cache rules**
InMemory / Redis
• invalid if api key updated or deleted
• invalid after 3600s`"]:::noteStyle
NOTE_ARGON["`**Hashing strategy**
Argon2: concat(secret, pepper)
Bcrypt: HMAC-SHA256(pepper, secret)
→ fixed 32 bytes, no 72-byte truncation`"]:::noteStyle
NOTE_SLEEP["`**sleep random (0.1s – 0.5s)**
makes brute force less effective;
randomization helps prevent
timing attacks`"]:::noteStyle
NOTE_FORMAT ~~~ INPUT
NOTE_CACHE ~~~ CACHE_STORE
NOT