Skip to content

Authentication Security

This document details the security properties of the Spotlight authentication system: API key hashing, JWT validation with JWKS, origin lockdown, and admin detection strategies.

API Key Security

Hash-Based Storage

API keys are never stored in plaintext. The full key is SHA-256 hashed before persistence, and only the hash is stored in DynamoDB:

python
full_key = f"sk_{environment}_{secrets.token_urlsafe(32)}"
hashed_key = hashlib.sha256(full_key.encode()).hexdigest()

# Stored in DynamoDB:
# {
#   "api_key_prefix": "sk_live_a1b2",     <-- first 12 chars (lookup index)
#   "api_key_hash": "e3b0c44298fc...",     <-- SHA-256 of full key
#   "tenant_id": "tenant_abc",
#   "environment": "live",
#   "created_at": "2025-05-10T00:00:00Z"
# }

The full key is returned exactly once at creation time. There is no "retrieve key" endpoint.

Prefix-Based Lookup

The first 12 characters of the key serve as a non-sensitive lookup index. This avoids scanning the entire table while not exposing the full key in the DynamoDB partition key:

python
_API_KEY_PREFIX_LENGTH = 12

prefix = api_key[:12]  # "sk_live_a1b2"
# Used as the DynamoDB primary key for O(1) lookup

Constant-Time Comparison

Hash comparison uses secrets.compare_digest to prevent timing attacks:

python
# Vulnerable: early-exit comparison
if stored_hash == expected_hash:  # DO NOT use this

# Secure: constant-time comparison
if secrets.compare_digest(stored_hash, expected_hash):  # correct

compare_digest takes the same amount of time regardless of how many bytes match, preventing an attacker from deducing the hash value byte-by-byte through timing measurements.

Cache-Poisoning Prevention

Successful API key validations are cached for 10 minutes to avoid repeated DynamoDB lookups. Invalid keys are NOT cached:

python
# Cache key includes a hash fragment for uniqueness
cache_key = f"apikey:{prefix}:{expected_hash[:8]}"

# Only valid results are cached -- None results are not stored
# This prevents an attacker from flooding the cache with invalid keys

If negative results were cached, an attacker could fill the cache with None entries for legitimate key prefixes, effectively denying service to valid API keys.

JWT Validation with JWKS

Tenant-Specific JWKS

Each tenant configures its own JWKS endpoint, audience, and issuer. This allows Spotlight to work with any standards-compliant identity provider:

json
{
  "tenant_id": "tenant_abc",
  "jwks_url": "https://auth.example.com/.well-known/jwks.json",
  "jwt_audience": "https://api.example.com",
  "jwt_issuer": "https://auth.example.com/"
}

Validation Checks

The JWT validator performs the following checks:

  1. Signature verification -- the token signature is verified against the public key from the JWKS.
  2. Algorithm restriction -- only RS256 and ES256 are accepted (configurable via JWT_ALGORITHMS).
  3. Audience validation -- the aud claim must match the tenant's configured jwt_audience.
  4. Issuer validation -- the iss claim must match the tenant's configured jwt_issuer.
  5. Expiration -- the exp claim is checked (handled by python-jose).
python
claims = jwt.decode(
    token,
    jwks,
    algorithms=settings.jwt_algorithm_list,  # ["RS256", "ES256"]
    audience=tenant_config["jwt_audience"],
    issuer=tenant_config["jwt_issuer"],
)

JWKS Caching

JWKS responses are cached in-memory for 1 hour to avoid hitting the identity provider on every request:

python
_JWKS_CACHE_TTL_SECONDS = 3600  # 1 hour

jwks = await self._cache.get_or_fetch(
    key=f"jwks:{jwks_url}",
    fetcher=lambda: self._do_fetch_jwks(jwks_url),
    ttl=_JWKS_CACHE_TTL_SECONDS,
    cache_name="jwks",
)

Key Rotation Handling

When a JWT fails validation, the JWKS cache is invalidated so the next request fetches fresh keys:

python
claims = self._decode_token(token, jwks, tenant_config)
if claims is None:
    self._cache.invalidate(f"jwks:{jwks_url}")
    logger.debug("jwt.jwks_cache_invalidated", url=jwks_url)

This gracefully handles key rotation without manual intervention. The sequence:

  1. Token signed with new key fails validation (old JWKS cached).
  2. Cache invalidated.
  3. Next request fetches fresh JWKS from the IdP.
  4. Token validates successfully with the new key.
  5. Fresh JWKS cached for 1 hour.

One retry per rotation

The first request after key rotation will fail. The second request will succeed. This is acceptable for interactive flows. For background/batch processing, implement retry logic on 401 responses.

JWKS Fetch Security

  • HTTPS only -- JWKS URLs should always use HTTPS. The HTTP client does not follow redirects to HTTP.
  • Timeout -- 10-second timeout prevents slow-loris attacks on the JWKS endpoint.
  • Trusted source -- The JWKS URL comes from the tenant config (admin-controlled), not from user input.
python
response = await client.get(jwks_url, timeout=10.0)
response.raise_for_status()

Origin Lockdown

Implementation

Origin checking occurs after tenant resolution (API key validation), because the allowed origins list comes from the tenant configuration:

python
def check_origin_allowed(
    request_origin: str | None,
    allowed_origins: list[str],
) -> bool:

Pattern Matching

Three matching modes are supported:

python
# Pattern: "https://app.example.com"
# Matches: "https://app.example.com"
# Rejects: "http://app.example.com" (wrong scheme)
origin_full == pattern
python
# Pattern: "*.example.com"
# Matches: "app.example.com", "staging.app.example.com"
# Rejects: "evil.com"
if pattern.startswith("*."):
    domain = pattern[2:]  # "example.com"
    return origin_host == domain or origin_host.endswith(f".{domain}")
python
# Pattern: "example.com" (no scheme)
# Matches: "http://example.com", "https://example.com"
# Rejects: "sub.example.com"
if "://" not in pattern:
    return origin_host == pattern

Security Properties

ScenarioBehaviorRationale
No Origin headerAllowedServer-to-server calls (curl, Lambda, cron jobs)
Empty allowed_origins + browser request403 ForbiddenDefault-deny: a tenant with nothing configured can still be used server-to-server but NOT from a browser, so a fresh tenant isn't an open relay
ENVIRONMENT=local / dev / developmentAll allowedLocal development convenience (see _is_local_mode in middleware/origin.py)
Origin not in list403 ForbiddenUnauthorized domain

Defense in depth

Origin checking is one layer in the authentication stack. Even without origin lockdown, attackers still need a valid API key and JWT. Origin lockdown provides additional protection against stolen API keys being used from unauthorized domains.

Admin Detection Strategies

Strategy Pattern

Admin detection is implemented as a pluggable strategy, configured per tenant. The AdminStrategy abstract base class defines the interface:

python
class AdminStrategy(ABC):
    @abstractmethod
    async def is_admin(
        self, tenant_id: str, user_id: str, jwt_claims: dict[str, Any]
    ) -> bool: ...

Available Strategies

Table Lookup (default)

Checks the Spotlight.Admin.Users DynamoDB table for a matching tenant_id + user_id entry:

python
class TableLookupStrategy(AdminStrategy):
    async def is_admin(self, tenant_id, user_id, jwt_claims):
        resp = dynamodb.get_item(
            Key={
                "tenant_id": {"S": tenant_id},
                "user_id": {"S": user_id},
            }
        )
        return "Item" in resp

Security properties:

  • Admin list is fully controlled by the tenant via the admin API.
  • No dependency on JWT claim structure.
  • Requires a DynamoDB read on every admin request (cached in practice).

JWT Permission

Checks the JWT permissions array for a configurable scope. Designed for Auth0 RBAC:

python
class JwtPermissionStrategy(AdminStrategy):
    def __init__(self, permission="spotlight:admin"):
        self._permission = permission

    async def is_admin(self, tenant_id, user_id, jwt_claims):
        permissions = jwt_claims.get("permissions", [])
        return self._permission in permissions

Security properties:

  • Admin status is managed entirely in the IdP.
  • No database lookup required.
  • Relies on JWT integrity (signature must be valid).

JWT Role

Checks a custom JWT claim for a specific role value. Supports both string and array claims:

python
class JwtRoleStrategy(AdminStrategy):
    def __init__(self, claim_name="role", admin_value="admin"):
        self._claim_name = claim_name
        self._admin_value = admin_value

    async def is_admin(self, tenant_id, user_id, jwt_claims):
        value = jwt_claims.get(self._claim_name)
        if isinstance(value, str):
            return value == self._admin_value
        if isinstance(value, list):
            return self._admin_value in value
        return False

Composite Strategy

Chains multiple strategies -- the first True result wins:

python
class CompositeStrategy(AdminStrategy):
    def __init__(self, strategies: list[AdminStrategy]):
        self._strategies = strategies

    async def is_admin(self, tenant_id, user_id, jwt_claims):
        for strategy in self._strategies:
            if await strategy.is_admin(tenant_id, user_id, jwt_claims):
                return True
        return False

Always False (Fallback)

Used when an unknown strategy name is configured. No one gets admin access:

python
class AlwaysFalseStrategy(AdminStrategy):
    async def is_admin(self, tenant_id, user_id, jwt_claims):
        return False

Strategy Selection

The factory function creates the appropriate strategy based on the tenant configuration:

python
_STRATEGY_MAP = {
    "table_lookup": TableLookupStrategy,
    "jwt_permission": JwtPermissionStrategy,
    "jwt_permissions": JwtPermissionStrategy,  # alias
    "jwt_role": JwtRoleStrategy,
}

strategy = create_strategy(
    name=tenant_config["admin_strategy"],  # e.g., "jwt_permission"
    client=dynamodb_client,
    settings=settings,
)

If the configured strategy name is unknown, the factory logs a warning and returns AlwaysFalseStrategy -- failing closed rather than open.

Authentication Flow Summary

Request arrives
    |
    +-- (1) X-API-Key header present?
    |       No  -> 401 Unauthorized
    |       Yes -> validate against Spotlight.Tenants.ApiKeys
    |              Invalid -> 401 Unauthorized
    |              Valid   -> extract tenant_id
    |
    +-- (2) Load tenant config (JWKS URL, allowed origins, admin strategy)
    |
    +-- (3) Origin header matches allowed_origins?
    |       No  -> 403 Forbidden
    |       Yes -> continue
    |
    +-- (4) Authorization: Bearer header present?
    |       No  -> 401 Unauthorized
    |       Yes -> validate JWT against tenant JWKS
    |              Invalid -> 401 Unauthorized
    |              Valid   -> extract user claims
    |
    +-- (5) Is this an admin endpoint?
    |       No  -> proceed to route handler
    |       Yes -> run admin strategy
    |              Not admin -> 403 Forbidden
    |              Admin     -> proceed to route handler
    |
    v
Route handler executes with AuthContext(tenant_id, user_id, is_admin, claims)

Spotlight