Authentication
The Spotlight API uses a dual-layer authentication model: an API key identifies the tenant, and a JWT token identifies (and optionally authorizes) the user. Both are required for SDK and admin endpoints.
Authentication Flow
Client Request
|
+-- X-API-Key: sk_live_... (1) Tenant identification
+-- Authorization: Bearer <jwt> (2) User authentication
+-- Origin: https://app.example.com (3) Origin validation
|
v
API Gateway (rate limiting, CORS)
|
v
Spotlight API
|
+-- (1) Validate API key --> resolve tenant_id
| - Prefix lookup in Spotlight.Tenants.ApiKeys
| - SHA-256 hash comparison (constant-time)
| - Result cached 10 minutes
|
+-- (2) Load tenant config from Spotlight.Tenants.Config
| - JWKS URL, audience, issuer
| - Allowed origins
| - Admin strategy
|
+-- (3) Validate Origin header against allowed_origins
|
+-- (4) Validate JWT against tenant's JWKS endpoint
| - Fetch JWKS (cached 1 hour)
| - Verify signature, audience, issuer
| - Extract user claims
|
+-- (5) For admin endpoints: run admin detection strategy
|
v
Route handler executesAPI Key Authentication
API keys follow the format sk_{environment}_{random}:
sk_live_a1b2c3d4e5f6g7h8i9j0... # Production key
sk_test_x9y8z7w6v5u4t3s2r1q0... # Test/staging keyKey Properties
| Property | Value |
|---|---|
| Format | sk_{live|test}_{random} |
| Random portion | secrets.token_urlsafe(32) |
| Storage | SHA-256 hash (plaintext never stored) |
| Lookup | First 12 characters used as prefix index |
| Cache TTL | 10 minutes (validated results only) |
| Header | X-API-Key (configurable via API_KEY_HEADER) |
Key Validation
# 1. Extract prefix (first 12 chars) for DynamoDB lookup
prefix = api_key[:12]
# 2. Hash the full key for comparison
expected_hash = hashlib.sha256(api_key.encode()).hexdigest()
# 3. Look up by prefix in Spotlight.Tenants.ApiKeys
item = dynamodb.get_item(Key={"api_key_prefix": prefix})
# 4. Constant-time comparison of stored hash vs. computed hash
secrets.compare_digest(stored_hash, expected_hash)
# 5. Return tenant_id and environment on success
# Result: {"tenant_id": "tenant_abc", "environment": "live"}Negative results are not cached
Invalid API key lookups return None and are NOT cached. This prevents cache-poisoning attacks where an attacker sends invalid keys to fill the cache with negative entries.
Key Lifecycle
Keys are generated via the admin API and the plaintext is returned exactly once:
curl -X POST https://api.example.com/v1/admin/api-keys \
-H "Authorization: Bearer <admin-jwt>" \
-d '{"environment": "live"}'{
"api_key": "sk_live_a1b2c3d4e5f6g7h8i9j0...",
"prefix": "sk_live_a1b2",
"environment": "live",
"created_at": "2025-05-10T00:00:00Z"
}Store the key securely
The full API key is only returned at creation time. It is not stored anywhere in plaintext. If lost, a new key must be generated.
JWT Validation
JWTs are validated against the tenant's JWKS (JSON Web Key Set) endpoint. This allows Spotlight to work with any identity provider that publishes JWKS (Auth0, Cognito, Okta, Firebase, custom IdPs).
Tenant Configuration
Each tenant record in Spotlight.Tenants.Config includes JWT settings:
{
"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/",
"admin_strategy": "jwt_permission",
"allowed_origins": ["https://app.example.com", "*.example.com"]
}Validation Process
class JwtValidator:
async def validate_token(self, token: str, tenant_config: dict) -> dict | None:
# 1. Fetch JWKS from tenant's endpoint (cached 1 hour)
jwks = await self._fetch_jwks(tenant_config["jwks_url"])
# 2. Decode and validate the JWT
claims = jwt.decode(
token,
jwks,
algorithms=["RS256", "ES256"], # configurable via JWT_ALGORITHMS
audience=tenant_config["jwt_audience"],
issuer=tenant_config["jwt_issuer"],
)
# 3. If validation fails, invalidate JWKS cache
# (handles key rotation gracefully)
if claims is None:
self._cache.invalidate(f"jwks:{jwks_url}")
return claimsKey Rotation Handling
When JWT validation fails, the JWKS cache is automatically invalidated. The next validation attempt fetches fresh keys from the JWKS endpoint. This handles key rotation gracefully without manual intervention:
Request 1: JWT signed with key-id "abc"
-> JWKS cached, contains "abc" -> validation succeeds
IdP rotates keys: new key-id "def"
Request 2: JWT signed with key-id "def"
-> JWKS cached, missing "def" -> validation fails
-> Cache invalidated
Request 3: JWT signed with key-id "def"
-> JWKS re-fetched, contains "def" -> validation succeeds
-> New JWKS cached for 1 hourSupported Algorithms
Configured via the JWT_ALGORITHMS environment variable (comma-separated):
JWT_ALGORITHMS=RS256,ES256 # default| Algorithm | Type | Recommended For |
|---|---|---|
RS256 | RSA | Auth0, Cognito, Okta |
ES256 | ECDSA | Firebase, compact tokens |
Origin Lockdown
Each tenant can restrict which domains are allowed to make API requests. The Origin or Referer header is checked against the tenant's allowed_origins list:
# Supported origin patterns:
allowed_origins = [
"https://app.example.com", # Exact match
"*.example.com", # Wildcard subdomain
"example.com", # Host-only (any scheme)
]Matching Rules
| Pattern | Matches | Does Not Match |
|---|---|---|
https://app.example.com | https://app.example.com | http://app.example.com |
*.example.com | app.example.com, staging.example.com | evil.com |
example.com | http://example.com, https://example.com | sub.example.com |
Special Cases
- No
Originheader -- allowed (server-to-server calls, curl, Lambda invocations). - Empty
allowed_originslist -- all origins allowed (tenant has not configured lockdown yet). - Local development -- when
ENVIRONMENTislocal,development, ordev, all origins are allowed.
Demo Mode
For local development and demos, the API supports a simplified authentication mode when ALLOW_DEMO_AUTH=true:
# In docker-compose.yml:
ALLOW_DEMO_AUTH=trueIn demo mode:
- API key validation uses a seeded demo key.
- JWT validation accepts demo tokens.
- Origin checks are bypassed.
Production safety
ALLOW_DEMO_AUTH must NEVER be set to true in production environments. The application enforces this by checking the ENVIRONMENT variable.
Request Headers Summary
| Header | Required | Purpose |
|---|---|---|
X-API-Key | Yes | Tenant identification |
Authorization | Yes | Bearer <jwt> for user authentication |
Origin | Conditional | Checked against tenant's allowed origins |
X-Request-Id | No | Client-generated request ID for tracing |
Error Responses
| Status | Condition |
|---|---|
401 Unauthorized | Missing or invalid API key |
401 Unauthorized | Missing or invalid JWT |
403 Forbidden | Origin not in tenant's allowed list |
403 Forbidden | User is not an admin (admin endpoints only) |
429 Too Many Requests | Rate limit exceeded |