Skip to content

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 executes

API Key Authentication

API keys follow the format sk_{environment}_{random}:

sk_live_a1b2c3d4e5f6g7h8i9j0...    # Production key
sk_test_x9y8z7w6v5u4t3s2r1q0...    # Test/staging key

Key Properties

PropertyValue
Formatsk_{live|test}_{random}
Random portionsecrets.token_urlsafe(32)
StorageSHA-256 hash (plaintext never stored)
LookupFirst 12 characters used as prefix index
Cache TTL10 minutes (validated results only)
HeaderX-API-Key (configurable via API_KEY_HEADER)

Key Validation

python
# 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:

bash
curl -X POST https://api.example.com/v1/admin/api-keys \
  -H "Authorization: Bearer <admin-jwt>" \
  -d '{"environment": "live"}'
json
{
  "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:

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/",
  "admin_strategy": "jwt_permission",
  "allowed_origins": ["https://app.example.com", "*.example.com"]
}

Validation Process

python
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 claims

Key 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 hour

Supported Algorithms

Configured via the JWT_ALGORITHMS environment variable (comma-separated):

bash
JWT_ALGORITHMS=RS256,ES256   # default
AlgorithmTypeRecommended For
RS256RSAAuth0, Cognito, Okta
ES256ECDSAFirebase, 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:

python
# Supported origin patterns:
allowed_origins = [
    "https://app.example.com",     # Exact match
    "*.example.com",                # Wildcard subdomain
    "example.com",                  # Host-only (any scheme)
]

Matching Rules

PatternMatchesDoes Not Match
https://app.example.comhttps://app.example.comhttp://app.example.com
*.example.comapp.example.com, staging.example.comevil.com
example.comhttp://example.com, https://example.comsub.example.com

Special Cases

  • No Origin header -- allowed (server-to-server calls, curl, Lambda invocations).
  • Empty allowed_origins list -- all origins allowed (tenant has not configured lockdown yet).
  • Local development -- when ENVIRONMENT is local, development, or dev, all origins are allowed.

Demo Mode

For local development and demos, the API supports a simplified authentication mode when ALLOW_DEMO_AUTH=true:

bash
# In docker-compose.yml:
ALLOW_DEMO_AUTH=true

In 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

HeaderRequiredPurpose
X-API-KeyYesTenant identification
AuthorizationYesBearer <jwt> for user authentication
OriginConditionalChecked against tenant's allowed origins
X-Request-IdNoClient-generated request ID for tracing

Error Responses

StatusCondition
401 UnauthorizedMissing or invalid API key
401 UnauthorizedMissing or invalid JWT
403 ForbiddenOrigin not in tenant's allowed list
403 ForbiddenUser is not an admin (admin endpoints only)
429 Too Many RequestsRate limit exceeded

Spotlight