Skip to content

Athroniaeth/keyshield

Keyshield

Python Version from PEP 621 TOML Tested with pytest PyPI version Docs codecov Security: bandit Deps: uv Code style: Ruff

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.

Links

Features

  • Security-first: secrets are hashed with a salt and a pepper, and never logged or returned after creation
  • Prod-ready: services and repositories are async, and battle-tested
  • Agnostic hasher: choose between Argon2 (default) or Bcrypt hashing strategies (with caching support)
  • Agnostic backend: abstract repository pattern, currently with SQLAlchemy implementation
  • Connectors: FastAPI, Litestar, Quart, and Django routers, plus Typer CLI for API key management
  • Envvar support: easily configure peppers and other secrets via environment variables
  • Scopes support: assign scopes to API keys for fine-grained access control

Standards compliance

This library try to follow best practices and relevant RFCs for API key management and authentication:

  • RFC 9110/7235: Router raise 401 for missing/invalid keys, 403 for valid but inactive/expired keys
  • RFC 6750: Supports Authorization: Bearer <api_key> header for key transmission (also supports deprecated X-API-Key header and api_key query param)
  • OWASP API2:2023: Hash verification is performed before status/scope checks to prevent key-state enumeration — a caller with a wrong secret always receives 401 Invalid, regardless of whether the key is inactive or expired.
  • NIST SP 800-132: The Bcrypt hasher pre-hashes the secret via HMAC-SHA256(pepper, secret) before passing it to bcrypt, producing a fixed 32-byte digest that eliminates bcrypt's silent 72-byte input truncation.
  • Input validation (defense-in-depth): Scope strings are validated against a strict allowlist pattern (^[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.
  • Versioned key format (industry best practice, aligned with Stripe/GitHub 2023+): the default prefix embeds a format version (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.

Installation

Basic installation

This project is published to PyPI. Use a tool like uv to manage dependencies.

uv add keyshield
uv pip install keyshield

Development installation

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]"

Optional dependencies

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]"

Makefile helpers

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.

Quick start

Use the service with an in-memory repository

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,
)

How API Keys Work

API Key Format

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

  • "-" separators so that systems can easily split
  • Prefix 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).
  • 16 first characters are the identifier (UUIDv4 without dashes)
  • 64 last characters are the secret (random alphanumeric string)

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.

Schema validation

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
    NOTE_ARGON ~~~ COMPARE
    NOTE_SLEEP ~~~ REJECT

Loading

Mount the FastAPI router

This example uses SQLAlchemy with FastAPI. It creates the database tables at startup if they do not exist.

import os
from pathlib import Path
from typing import AsyncIterator

from fastapi import FastAPI, Depends, APIRouter
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

from keyshield import ApiKey, ApiKeyService
from keyshield.hasher.argon2 import Argon2ApiKeyHasher
from keyshield.repositories.sql import SqlAlchemyApiKeyRepository
from keyshield.api import create_api_keys_router, create_depends_api_key

# Set env var to override default pepper
# Using a strong, unique pepper is crucial for security
# Default pepper is insecure and should not be used in production
pepper = os.getenv("SECRET_PEPPER")
hasher = Argon2ApiKeyHasher(pepper=pepper)

path = Path(__file__).parent / "db.sqlite3"
database_url = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{path}")

async_engine = create_async_engine(database_url, future=True)
async_session_maker = async_sessionmaker(
    async_engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

app = FastAPI(title="API with API Key Management")


async def inject_async_session() -> AsyncIterator[AsyncSession]:
    """Dependency to provide an active SQLAlchemy async session."""
    async with async_session_maker() as session:
        async with session.begin():
            yield session


async def inject_svc_api_keys(async_session: AsyncSession = Depends(inject_async_session)) -> ApiKeyService:
    """Dependency to inject the API key service with an active SQLAlchemy async session."""
    repo = SqlAlchemyApiKeyRepository(async_session)

    # Necessary if you don't use your own DeclarativeBase
    await repo.ensure_table(async_engine=async_engine)

    return ApiKeyService(repo=repo, hasher=hasher)


security = create_depends_api_key(inject_svc_api_keys)
router_protected = APIRouter(prefix="/protected", tags=["Protected"])

router = APIRouter(prefix="/api-keys", tags=["API Keys"])
router_api_keys = create_api_keys_router(
    inject_svc_api_keys,
    router=router,
)


@router_protected.get("/")
async def read_protected_data(api_key: ApiKey = Depends(security)):
    return {
        "message": "This is protected data",
        "apiKey": {
            "id": api_key.id_,
            "name": api_key.name,
            "description": api_key.description,
            "isActive": api_key.is_active,
            "createdAt": api_key.created_at,
            "expiresAt": api_key.expires_at,
            "lastUsedAt": api_key.last_used_at,
        },
    }


app.include_router(router_api_keys)
app.include_router(router_protected)

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="localhost", port=8000)

The router exposes:

  • POST /api-keys - create a key and return the plaintext secret once.
  • GET /api-keys - list keys with offset/limit pagination.
  • GET /api-keys/{id} - fetch a key by identifier.
  • PATCH /api-keys/{id} - update name, description, or active flag.
  • DELETE /api-keys/{id} - remove a key.

Contributing

Additional notes

  • Python 3.9+ is required.
  • The library issues warnings if you keep the default pepper; always configure a secret value outside source control.
  • Never log peppers or plaintext API keys, change the pepper of prod will prevent you from reading API keys

About

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.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Languages