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:
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:
_API_KEY_PREFIX_LENGTH = 12
prefix = api_key[:12] # "sk_live_a1b2"
# Used as the DynamoDB primary key for O(1) lookupConstant-Time Comparison
Hash comparison uses secrets.compare_digest to prevent timing attacks:
# 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): # correctcompare_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:
# 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 keysIf 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:
{
"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:
- Signature verification -- the token signature is verified against the public key from the JWKS.
- Algorithm restriction -- only
RS256andES256are accepted (configurable viaJWT_ALGORITHMS). - Audience validation -- the
audclaim must match the tenant's configuredjwt_audience. - Issuer validation -- the
issclaim must match the tenant's configuredjwt_issuer. - Expiration -- the
expclaim is checked (handled bypython-jose).
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:
_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:
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:
- Token signed with new key fails validation (old JWKS cached).
- Cache invalidated.
- Next request fetches fresh JWKS from the IdP.
- Token validates successfully with the new key.
- 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.
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:
def check_origin_allowed(
request_origin: str | None,
allowed_origins: list[str],
) -> bool:Pattern Matching
Three matching modes are supported:
# Pattern: "https://app.example.com"
# Matches: "https://app.example.com"
# Rejects: "http://app.example.com" (wrong scheme)
origin_full == pattern# 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}")# Pattern: "example.com" (no scheme)
# Matches: "http://example.com", "https://example.com"
# Rejects: "sub.example.com"
if "://" not in pattern:
return origin_host == patternSecurity Properties
| Scenario | Behavior | Rationale |
|---|---|---|
No Origin header | Allowed | Server-to-server calls (curl, Lambda, cron jobs) |
Empty allowed_origins + browser request | 403 Forbidden | Default-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 / development | All allowed | Local development convenience (see _is_local_mode in middleware/origin.py) |
| Origin not in list | 403 Forbidden | Unauthorized 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:
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:
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 respSecurity 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:
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 permissionsSecurity 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:
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 FalseComposite Strategy
Chains multiple strategies -- the first True result wins:
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 FalseAlways False (Fallback)
Used when an unknown strategy name is configured. No one gets admin access:
class AlwaysFalseStrategy(AdminStrategy):
async def is_admin(self, tenant_id, user_id, jwt_claims):
return FalseStrategy Selection
The factory function creates the appropriate strategy based on the tenant configuration:
_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)