Skip to content

Security & Authentication

Candela uses a multi-strategy authentication system designed to serve three distinct client types: browser users, developer CLI tools, and service accounts.

flowchart TD
REQ[Incoming Request] --> SKIP{Path = /healthz?}
SKIP -->|yes| PASS[Pass through]
SKIP -->|no| DEV{Dev Mode?}
DEV -->|yes| SYNTH["Inject synthetic admin<br/>(admin@localhost)"]
DEV -->|no| XCA{Has X-Candela-Auth?}
XCA -->|yes| S3X["Strategy 3:<br/>OAuth2 Access Token<br/>(from X-Candela-Auth)"]
S3X -->|valid| AUTH[Authenticated ✓]
S3X -->|invalid| DENY["401 Unauthorized"]
XCA -->|no| TOK{Has Bearer Token?}
TOK -->|no| DENY
TOK -->|yes| S1["Strategy 1:<br/>Firebase ID Token"]
S1 -->|valid| AUTH
S1 -->|invalid| S2["Strategy 2:<br/>Google ID Token"]
S2 -->|valid| AUTH
S2 -->|invalid| S3["Strategy 3:<br/>OAuth2 Access Token"]
S3 -->|valid| AUTH
S3 -->|invalid| DENY
AUTH --> CTX["User injected into<br/>request context"]
CTX --> HANDLER[ConnectRPC Handler]

The middleware checks X-Candela-Auth first (sent by candela behind IAP). If absent, it falls back to three strategies on the Authorization header — the first successful validation wins:

#StrategyClientToken Source
X-Candela-Auth (priority)candela behind IAPUser’s ADC OAuth2 access token
1Firebase ID TokenBrowser UIFirebase JS SDK
2Google ID TokenService accounts, candela CLI (no IAP)idtoken.NewTokenSource(audience)
3OAuth2 Access Tokencandela with user ADC (no IAP)candela auth login (or gcloud auth application-default login)

Candela uses a deny-by-default service account policy. All service account tokens are rejected with 403 Forbidden unless explicitly allowlisted:

config.yaml
auth:
allowed_service_accounts:
- "candela-ci@my-project.iam.gserviceaccount.com"

If the allowed_service_accounts list is empty or omitted, all service accounts are blocked. This prevents unmetered cost vectors — SA traffic bypasses per-user budget deduction.


RoleDescription
developerUse proxy, view own traces/costs, self-service RPCs
adminFull access: manage users, budgets, view all data

Self-Service RPCs (any authenticated user):

RPCDescription
GetCurrentUserReturns the caller’s own profile, budget, and active grants
GetMyBudgetReturns the caller’s budget and current-period spending

Admin-Only RPCs:

CategoryRPCs
UsersCreateUser, ListUsers, GetUser, UpdateUser, DeactivateUser, ReactivateUser
BudgetsSetBudget, GetBudget, ResetSpend
GrantsCreateGrant, ListGrants, RevokeGrant
AuditListAuditLog

Non-admin developers can only see their own traces. This is enforced at two levels:

  1. Query-time filtering — All list/search endpoints inject user_id filters into storage queries. Admins see all data; developers see only their own.

  2. Post-fetch auth gateGetTrace (which queries by trace ID, not user) fetches the full trace, extracts the owner, and returns PermissionDenied on mismatch.

All storage backends (BigQuery, DuckDB, SQLite) apply the filter in SQL:

AND (? = '' OR user_id = ?)

ModeAuth RequiredHow
SoloNoAll requests to :1234 and :8181 are unauthenticated
Solo + CloudADC onlycandela auth login — tokens used for upstream Vertex AI calls
Team (no IAP)OIDCcandela injects a Google ID token as Authorization: Bearer
Team (IAP)Dual-tokencandela sends IAP OIDC + user identity via X-Candela-Auth

When candela authenticates with SA credentials (e.g., Workload Identity, SA key), it mints a single OIDC ID token:

SA Credential → idtoken.NewTokenSource(audience) → single ID token
└── Authorization: Bearer <oidc-id-token>

The server validates this via Strategy 2 (Google ID Token).

Strategy 1.5: Dual-Token for IAP (User ADC + iap_service_account)

Section titled “Strategy 1.5: Dual-Token for IAP (User ADC + iap_service_account)”

When iap_service_account is set in the config and the developer has user ADC, candela sends two tokens on every cloud-model request:

IDE → candela (:1234)
├── Local model → Ollama (no auth)
└── Cloud model → Candela Server (behind IAP)
│ Proxy-Authorization: Bearer <impersonated-sa-oidc-token>
│ X-Candela-Auth: Bearer <user-adc-oauth2-access-token>
IAP (validates Proxy-Authorization)
│ ← IAP replaces Authorization with its own JWT
Auth Middleware (reads X-Candela-Auth for user identity)

Token breakdown:

HeaderValuePurpose
Proxy-AuthorizationImpersonated SA OIDC ID tokenAuthenticates to IAP
X-Candela-AuthUser’s ADC OAuth2 access tokenCarries the developer’s real identity to the server

The impersonation flow:

User ADC → impersonate iap_service_account → generateIdToken(audience) → IAP OIDC token

This uses a custom iapIdTokenCreator IAM role bound to the service account, which grants only:

PermissionIncluded
iam.serviceAccounts.getOpenIdToken
iam.serviceAccounts.getAccessToken

Without iap_service_account, candela sends the user’s ADC access token directly:

User ADC → oauth2.TokenSource → access token
└── Authorization: Bearer <user-access-token>

The server validates this via Strategy 3 (OAuth2 Access Token → userinfo endpoint).


All requests are validated server-side using protovalidate. Key constraints:

FieldConstraint
IngestSpans batchMax 1,000 spans
GenAI content fieldsMax 1 MB
Pagination page_size[0, 1000]
All ID fieldsMax 128 chars

ItemStatus
Token validation on all non-health endpoints
Email claim normalization (lowercase)
Admin role enforcement via ConnectRPC interceptor
Per-user trace/span data isolation
Internal error message sanitization
Rate limiting per user
Budget enforcement before proxy calls
Secrets not baked into container images
ADC token auto-refresh
API key hashing (bcrypt)
Proxy does not store upstream API keys
CORS origin allowlist
Audit logging for admin actions
eBPF enforcement (Tetragon + Cilium + iptables)
Circuit breaker resilience for upstream providers
Fuzz testing for proxy SSE parser
Tetragon gRPC audit pipeline with graceful shutdown
Multi-cloud auth (GCP OAuth2 + AWS SSO)