/config/auth.llms.txt
Authentication
Every request eRPC receives can be checked before it ever touches an upstream. Stack as many strategies as you need — static token, signed JWT, Ethereum wallet signature, or IP allowlist — and the first one that passes wins. Tie each identity directly to a rate-limit budget so power users and free-tier callers never share the same quota.
Quick taste
Illustrative, not a tuned production config — one secret token, one budget:
projects: - id: main auth: strategies: - type: secret # bind this identity to its own rate-limit budget rateLimitBudget: backend-tier secret: id: backend value: ${MY_SECRET_VALUE}Agent reference
Copy one of these prompts into your AI agent session (Claude Code, Cursor, …) — each one points the agent at this page's machine-readable reference so it can do the work correctly:
Prompt Example #1: lock down my project with a static token
Add a secret-token auth strategy to my eRPC project so that only callers who supply the correct token in X-ERPC-Secret-Token (or ?secret=) can reach any upstream. Bind the token to an existing rate-limit budget so the backend tier gets its own quota. Work with my existing eRPC config. Read the full reference first: https://docs.erpc.cloud/config/auth.llms.txt
Prompt Example #2: set up JWT auth with per-user rate-limit tiers
Configure eRPC to verify signed JWT bearer tokens (RS256) for my public-facing project. Different token tiers should get different rate-limit budgets — embed the budget ID in the token's rlm claim so a single strategy handles all users without separate config entries. Include key rotation via kid so I can add a new key without downtime. Work with my existing eRPC config. Reference: https://docs.erpc.cloud/config/auth.llms.txt
Prompt Example #3: migrate to database auth without a redeploy
Replace my static secret strategy in my eRPC config with a database strategy backed by PostgreSQL so I can add and revoke API keys in the database without redeploying eRPC. Enable fail-open with an emergency rate-limit budget so the RPC endpoint stays reachable if the auth DB goes down. Reference: https://docs.erpc.cloud/config/auth.llms.txt
Prompt Example #4: debug why callers are getting 401 or no-strategy-matched
I'm seeing 401 responses and "no auth strategy matched" errors in my eRPC logs. Walk through the credential-extraction priority order and strategy matching logic to explain why my config in my eRPC config might be failing, and suggest fixes. Reference: https://docs.erpc.cloud/config/auth.llms.txt
Prompt Example #5: add IP allowlist as a credential-less fallback
My internal services call eRPC without any token. Add a network strategy that allowlists my private CIDR ranges and localhost so they get through, while still requiring a secret token for all other callers. Each internal IP should get its own User.Id in metrics (ipAsUser mode). Work with my existing eRPC config. Reference: https://docs.erpc.cloud/config/auth.llms.txt
Authentication — full agent referenceExpand for every option, default, and edge case — or copy this entire section into your AI assistant.
How it works
At startup, three independent AuthRegistry instances can be created: one per project (consumer traffic at projects[*].auth), one for the admin API (admin.auth), and one for the healthcheck endpoint (healthCheck.auth). If a project has no auth config, its registry is nil and all requests are allowed. If admin.auth is nil, the admin endpoint hard-errors — configure admin.auth to protect it.
For each HTTP request, eRPC extracts credential signals from query params and headers in a fixed priority order, then calls AuthRegistry.Authenticate. The registry iterates strategies in declaration order, skipping any whose type does not match the credential type and any filtered out by method rules. The first strategy that both matches and succeeds wins; its returned User{Id, RateLimitBudget} is attached to the request for downstream rate-limiting and observability.
HTTP credential extraction order (first match wins):
?token=— deprecated alias for?secret=→AuthTypeSecret?secret=→AuthTypeSecretX-ERPC-Secret-Tokenheader →AuthTypeSecretAuthorization: Basic(password part of base64user:pass) →AuthTypeSecretAuthorization: Bearer <token>→AuthTypeJwt?jwt=→AuthTypeJwt?signature=+?message=→AuthTypeSiweX-Siwe-Message+X-Siwe-Signatureheaders →AuthTypeSiwe- No credential present →
AuthTypeNetwork(fallback)
gRPC metadata uses a parallel order without query-param equivalents: x-erpc-secret-token, then authorization: Basic, authorization: Bearer, then x-siwe-message+x-siwe-signature, then network fallback. gRPC has no ?token= / ?secret= / ?jwt= equivalents — query params are not available in gRPC metadata.
Method filtering. Every strategy accepts ignoreMethods and allowMethods wildcard lists. ignoreMethods is evaluated first; allowMethods overrides it. The canonical pattern for restricting a strategy to one method is ignoreMethods: ["*"] plus allowMethods: ["eth_getLogs"].
Rate-limit budget cascade. A common.User{Id, RateLimitBudget} is returned from each strategy. Budget priority: (1) per-user budget from the strategy result (database record field, JWT claim, or strategy-level rateLimitBudget); (2) strategy-level AuthStrategyConfig.rateLimitBudget; (3) no budget → all requests pass. In acquireRateLimitPermit, the user's budget (if non-empty) overrides the strategy-level budget.
secret strategy
The simplest backend-to-backend gate. The client sends the token as ?secret=VALUE or the X-ERPC-Secret-Token header. Authorization: Basic is also accepted; only the password field is used — the username is silently discarded.
Authenticate: exact equality match (ap.Secret.Value != s.cfg.Value). Returns ErrAuthUnauthorized on mismatch. No timing-safe compare — plain Go string inequality. For public internet deployments, prefer database or jwt.
jwt strategy
Verifies a signed JWT bearer token. Supports RSA, EC, and HMAC keys with optional kid-based rotation. Rate-limit budget tiers can be embedded in any JWT claim, enabling per-user rate limiting without separate strategy entries.
Authenticate flow: ParseUnverified (kid extraction) → algorithm allowlist → key lookup by kid or type-compatibility → full Parse (signature + temporal claims) → issuer/audience/required claims → extract sub as User.Id → extract budget claim.
Critical footgun: when verificationKeys is nil or empty, s.keys is empty, key lookup finds nothing, and every JWT is rejected with "no suitable verification key found". An empty key map is deny-all, not allow-all.
siwe strategy
Verifies an EIP-4361 Sign-In-With-Ethereum message and signature. The recovered Ethereum address (lowercase) becomes User.Id. Requires an explicit allowedDomains list — an empty or absent list rejects all SIWE requests.
Authenticate flow: parse EIP-4361 message → EIP-191 signature verification → domain allowlist → ValidNow() temporal check → return User{Id: strings.ToLower(address)}.
Both raw and base64-encoded message forms are accepted. SIWE requires both ?signature= AND ?message= together — supplying only one causes silent fallthrough to network-strategy extraction.
network strategy
Authorizes requests by client IP address against an allowlist of exact IPs and CIDR ranges. This is the credential-less fallback — any request arriving without a recognizable token, JWT, or SIWE payload receives AuthTypeNetwork and is routed here if configured.
Authenticate: reads req.ClientIP() (proxy-resolved by HTTP ingress). Checks localhost → exact IP list → CIDR list. First match wins. ipAsUser=false (default): all IPs in a CIDR range share one identity (cidr.String()). ipAsUser=true: each IP gets its own identity (clientIP.String()).
database strategy
Looks up an API key against a connector-backed store (PostgreSQL, DynamoDB, Redis, memory, or gRPC). Includes an in-process Ristretto positive cache (default 1h TTL), a hardcoded 5-second negative cache for invalid/disabled keys, singleflight deduplication for concurrent misses, configurable retry/backoff, and a connector-down circuit-breaker with optional fail-open.
Authenticate fast paths (in evaluation order):
- Positive Ristretto cache hit → return cached
Userimmediately. - Negative Ristretto cache hit → return
ErrAuthUnauthorizedimmediately. - Connector-down fast path (
tryFastFailOpen) → return emergency user if fail-open enabled. - Singleflight deduplicated DB lookup → parse JSON → cache or negative-cache.
DB record format:
{
"userId": "string (required)",
"enabled": true,
"rateLimitBudget": "budget-id (optional)"
}enabled: false → ErrAuthUnauthorized + 5-second negative-cache entry. Missing userId → auth error. Missing enabled → treated as true.
Connector-down circuit-breaker (added after 2026-05-13 production incident). The strategy tracks connectorDown bool and connectorDownSince int64. On transport/timeout/not-ready errors, markConnectorDown() sets the latch. While down, tryFastFailOpen() bypasses singleflight and the DB entirely — returning the emergency user (if failOpen.enabled=true) or rejecting immediately. One probe per second is elected via CAS to run the real DB path. A successful probe calls markConnectorUp().
Config schema
Auth attachment points
| YAML path | Type | Default | Notes |
|---|---|---|---|
projects[*].auth | *AuthConfig | nil (allow-all) | Consumer auth per project. Nil = allow-all. common/config.go:L509 |
admin.auth | *AuthConfig | nil | Admin endpoint auth. Nil = admin endpoint always returns error. common/config.go:L249 |
healthCheck.auth | *AuthConfig | nil | Healthcheck auth. Nil = unguarded. Do NOT set rateLimitBudget on healthcheck strategies — healthcheck auth registry has no rate-limiter registry and will panic. erpc/http_server.go:L201-205 |
AuthConfig
| Field | Type | Default | Notes |
|---|---|---|---|
auth.strategies | []*AuthStrategyConfig | nil (allow-all) | Ordered list. First strategy that matches credential type + method filter and authenticates successfully wins. common/config.go:L2444 |
AuthStrategyConfig — common fields
| Field | Type | Default | Notes |
|---|---|---|---|
strategies[*].type | string | Inferred from sub-config block | "secret", "jwt", "siwe", "network", "database". Block presence force-overwrites type for secret/database/jwt/siwe. For network, only type: "network" triggers auto-creation; the network block does not overwrite type. common/defaults.go:L2636-2666 |
strategies[*].ignoreMethods | []string | nil | Wildcard patterns (supports *, |, &, !). Applied before allowMethods. auth/authorizer.go:L86-98 |
strategies[*].allowMethods | []string | nil | Overrides ignoreMethods; any matching allow re-enables the strategy for that method. auth/authorizer.go:L100-113 |
strategies[*].rateLimitBudget | string | "" | Strategy-level budget ID. Overridden by per-user budget when non-empty. auth/authorizer.go:L119-127 |
secret strategy — SecretStrategyConfig
YAML prefix: auth.strategies[*].secret
| Field | Type | Default | Notes |
|---|---|---|---|
secret.id | string | "" | Returned as User.Id on success. May be empty. common/config.go:L2461 |
secret.value | string | required | Exact token to match. Plain string equality (not timing-safe). Redacted in JSON/YAML marshal. auth/strategy_secret.go:L24 |
secret.rateLimitBudget | string | "" | Attached to returned User.RateLimitBudget if non-empty. auth/strategy_secret.go:L29-31 |
Supports: AuthTypeSecret only. SetDefaults: no-op.
jwt strategy — JwtStrategyConfig
YAML prefix: auth.strategies[*].jwt
| Field | Type | Default | Notes |
|---|---|---|---|
jwt.verificationKeys | map[string]string | nil | Map of kid → keyData. Value is PEM string, "file:///path", or bare HMAC secret bytes. Empty map = ALL JWTs rejected (deny-all, not allow-all). auth/jwks.go |
jwt.verificationJwksUrl | string | "" | Absolute HTTP(S) URL returning a standard JWKS document ({"keys":[...]}). Fetched at startup and refreshed in the background. Static verificationKeys override JWKS entries with the same kid. Non-HTTP(S) or host-less values fail startup validation. common/validation.go |
jwt.verificationJwksRefreshInterval | Duration | 1h when JWKS URL set | Background JWKS refresh interval. common/defaults.go |
jwt.verificationJwksTlsInsecureSkipVerify | bool | false | Skip TLS certificate verification when fetching the JWKS URL. Do not enable in production — intended for local development with self-signed certificates only. auth/jwks.go |
jwt.allowedAlgorithms | []string | nil (any algorithm) | e.g. ["RS256","ES256"]. Prevents algorithm-confusion attacks. auth/strategy_jwt.go |
jwt.allowedIssuers | []string | nil (any issuer) | Exact match against iss claim. Non-empty list + missing iss → error. auth/strategy_jwt.go |
jwt.allowedAudiences | []string | nil (any audience) | Exact match against aud claim as scalar string. Array-form aud JWT claim always fails the cast. auth/strategy_jwt.go |
jwt.requiredClaims | []string | nil | Claim names that must be present (any value). auth/strategy_jwt.go |
jwt.claimMatchers | map[string][]string | nil | Map of claimName → allowedValues. Every key is an AND condition; within each key's value list any match passes (OR). Claim value may be a string, string array, or SCIM-style [{"value":"..."}]. Missing or empty claim → error. Omitting claimMatchers skips the check (backward-compatible). auth/strategy_jwt.go |
jwt.rateLimitBudgetClaimName | string | "rlm" (set by SetDefaults) | JWT claim name from which User.RateLimitBudget is extracted. Missing claim → empty budget (no rate-limit). Non-string value → silent no-op. To suppress, use a claim name never present in tokens. common/defaults.go:L2749-2751 |
Supports: AuthTypeJwt only. SetDefaults: sets rateLimitBudgetClaimName = "rlm" if empty.
siwe strategy — SiweStrategyConfig
YAML prefix: auth.strategies[*].siwe
| Field | Type | Default | Notes |
|---|---|---|---|
siwe.allowedDomains | []string | nil (deny-all) | Exact match against EIP-4361 domain field. Empty list = all SIWE rejected. Must supply at least one domain. auth/strategy_siwe.go:L59-67 |
siwe.rateLimitBudget | string | "" | Attached to User.RateLimitBudget if non-empty. auth/strategy_siwe.go:L53-55 |
Supports: AuthTypeSiwe only. SetDefaults: no-op.
network strategy — NetworkStrategyConfig
YAML prefix: auth.strategies[*].network
| Field | Type | Default | Notes |
|---|---|---|---|
network.allowedIPs | []string | nil | Exact IPv4/IPv6 addresses. Invalid IP → startup error. auth/strategy_network.go:L26-33 |
network.allowedCIDRs | []string | nil | CIDR ranges. Invalid CIDR → startup error. auth/strategy_network.go:L35-44 |
network.allowLocalhost | bool | false | When true, any loopback address (127.0.0.1, ::1) is allowed unconditionally before IP/CIDR checks. User.Id = clientIP.String(). auth/strategy_network.go:L63-71 |
network.trustedProxies | []string | nil | NO-OP. Never read by NetworkStrategy. Use server.trustedIPForwarders / server.trustedIPHeaders for X-Forwarded-For unwrapping. auth/strategy_network.go:L47-49 |
network.rateLimitBudget | string | "" | Attached to User.RateLimitBudget for all matching IPs. auth/strategy_network.go:L65-68 |
network.ipAsUser | bool | false | For CIDR matches: false → User.Id = cidr.String() (all IPs in range share one identity); true → User.Id = clientIP.String() (per-IP identity). Localhost and exact-IP matches always use clientIP.String(). auth/strategy_network.go:L63-93 |
Supports: AuthTypeNetwork only (credential-less fallback). SetDefaults: no-op.
database strategy — DatabaseStrategyConfig
YAML prefix: auth.strategies[*].database
| Field | Type | Default | Notes |
|---|---|---|---|
database.connector | *ConnectorConfig | auto-created | Drivers: postgresql, dynamodb, redis, memory, grpc. Connector id defaults to "auth-<driver>". common/defaults.go:L2676-2678 |
database.maxWait | Duration | 1s | Per-request context timeout for the full DB lookup (singleflight + retries). common/defaults.go:L2718-2720 |
database.cache.ttl | *time.Duration | 1h | Positive-cache TTL in Ristretto. common/defaults.go:L2691-2694 |
database.cache.maxSize | *int64 | 10000 | Max positive-cache entries. common/defaults.go:L2696-2699 |
database.cache.maxCost | *int64 | 1073741824 (1 GiB) | Ristretto MaxCost for positive cache. common/defaults.go:L2701-2704 |
database.cache.numCounters | *int64 | 100000 | Ristretto NumCounters (also reused for negative cache). common/defaults.go:L2706-2709 |
database.retry.maxAttempts | int | 3 | Max DB lookup attempts. common/defaults.go:L2726-2728 |
database.retry.baseBackoff | Duration | 100ms | Exponential backoff base; each attempt uses baseBackoff << (attempt-1). common/defaults.go:L2729-2731 |
database.failOpen.enabled | bool | false | When true, DB errors grant an emergency user instead of rejecting. The connector-down circuit-breaker fast path also respects this flag. auth/strategy_database.go:L519-530 |
database.failOpen.userId | string | "emergency-failopen" | User.Id for all fail-open authenticated requests. Flows into Prometheus labels and log fields. common/defaults.go:L2738-2740 |
database.failOpen.rateLimitBudget | string | "" | User.RateLimitBudget applied to fail-open traffic. auth/strategy_database.go:L526-528 |
Negative cache: hardcoded 5-second TTL, 1 MiB MaxCost. Not configurable. Disabled/invalid keys stay rejected up to 5 seconds after change.
database strategy Supports: accepts both AuthTypeSecret (token delivered via header/query) and AuthTypeDatabase. The AuthTypeDatabase enum value is never set by any current HTTP or gRPC payload extractor — it is reserved for future or programmatic use. auth/strategy_database.go:L118-120
database connector error classification — determines whether connectorDown latch is set (auth/strategy_database.go:L487-L517 (opens in a new tab)):
| Error | Label | Sets connectorDown? |
|---|---|---|
data.ErrConnectorNotReady | db_not_ready | yes |
context.DeadlineExceeded, substring "deadline exceeded" or "timeout" | db_timeout | yes |
substrings: "connection refused", "connection reset", "broken pipe", "no route to host", "EOF", "use of closed network connection" | db_connection | yes |
ErrRecordNotFound | (treated separately; not error-classified) | no — marks connector UP |
everything else (parse errors, Postgres 53300 "too many connections", syntax errors) | db_query_error | no |
getWithRetries abort conditions (auth/strategy_database.go:L367-L417 (opens in a new tab)):
ErrRecordNotFound→ abort immediately (DB is healthy; no retry needed).data.ErrConnectorNotReady→ abort immediately (connector's own reconnect loop handles recovery).- Context done → abort immediately.
- Any other error → retry with
baseBackoff << (attempt-1)up tomaxAttempts.
Connector failsafe wrapping. Auth-scope connectors support the same failsafeForGets and failsafeForSets wrapping as cache-scope connectors (ConnectorConfig.failsafeForGets[*] and failsafeForSets[*]). Each entry is a FailsafeConfig with fields matchMethod (default "*"), matchFinality, retry, circuitBreaker, timeout, hedge, and consensus. common/config.go:L349-350
PostgreSQL auth-scope defaults (connector.postgresql.*):
| Field | Auth-scope default | Notes |
|---|---|---|
table | "erpc_auth" | Differs from cache ("erpc_json_rpc_cache") and shared-state ("erpc_shared_state") scopes. common/defaults.go:L1028-1029 |
minConns | 1 | Differs from cache scope (4). common/defaults.go:L1034-1036 |
maxConns | 4 | Differs from cache scope (32). common/defaults.go:L1041-1043 |
initTimeout | 5s | common/defaults.go:L1048-1050 |
getTimeout | 1s | common/defaults.go:L1051-1053 |
setTimeout | 2s | common/defaults.go:L1054-1056 |
Memory connector (connector.memory.*):
| Field | Auth-scope default | Notes |
|---|---|---|
maxItems | 100000 | Ristretto item count limit. common/defaults.go:L936-938 |
maxTotalSize | "1GB" | Max total size of values. common/defaults.go:L939-941 |
emitMetrics | nil (disabled) | When true, a background goroutine emits Ristretto metrics to Prometheus every 30 seconds. Must be explicitly set to true to enable. data/memory.go:L71 |
DynamoDB auth-scope defaults (connector.dynamodb.*):
| Field | Auth-scope default | Notes |
|---|---|---|
table | "erpc_auth" | Differs from cache scope. common/defaults.go:L1068-1069 |
region | "" | AWS region. Required unless resolved from environment/instance profile. common/config.go:L430 |
endpoint | "" | Custom endpoint URL (for local DynamoDB or LocalStack). common/config.go:L431 |
auth | nil | AWS credential config; see AwsAuthConfig table below. common/config.go:L432 |
partitionKeyName | "groupKey" | common/defaults.go:L1074-1076 |
rangeKeyName | "requestKey" | common/defaults.go:L1077-1079 |
reverseIndexName | "idx_requestKey_groupKey" | GSI name. common/defaults.go:L1080-1082 |
ttlAttributeName | "ttl" | common/defaults.go:L1083-1085 |
initTimeout | 5s | SDK init timeout. common/defaults.go:L1086-1088 |
getTimeout | 1s | Per-get operation timeout. common/defaults.go:L1089-1091 |
setTimeout | 2s | Per-set operation timeout. common/defaults.go:L1092-1094 |
maxRetries | 0 | Max DynamoDB SDK retries. Zero means use SDK default (typically 3). common/config.go:L440 |
statePollInterval | 5s | Interval for polling DynamoDB connection state. common/defaults.go:L1095-1097 |
lockRetryInterval | 0 | Footgun: zero means busy-spin retries; set to e.g. 100ms in production. common/config.go:L442 |
AwsAuthConfig (used by connector.dynamodb.auth):
| Field | Default | Notes |
|---|---|---|
mode | "" (SDK default chain) | Valid values: "file" (reads credentials file), "env" (reads AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY), "secret" (uses accessKeyID/secretAccessKey fields directly). Empty = AWS SDK default chain (env → shared credentials file → EC2 instance profile). common/config.go:L479-485 |
credentialsFile | "" | Path to AWS credentials file (used when mode="file"). |
profile | "" | AWS named profile. Only meaningful when mode="file" — passed to credentials.NewSharedCredentials. Ignored for mode="env", mode="secret", and the SDK default chain. data/dynamodb.go:L177-187 |
accessKeyID | "" | AWS access key ID (used when mode="secret"). |
secretAccessKey | "" | AWS secret access key (used when mode="secret"). Redacted in JSON marshal. common/config.go:L487-495 |
Redis auth-scope defaults (connector.redis.*):
| Field | Auth-scope default | Notes |
|---|---|---|
uri | "" | Full Redis URI (e.g., redis://user:pass@host:6379/0). Mutually exclusive with addr — providing both causes a startup error. When set, addr/username/password/db are ignored entirely. common/defaults.go:L946-950 |
addr | "" | Redis host:port. Folded into uri and cleared by SetDefaults. Port defaults to 6379 when missing. After folding, addr is set to "". common/defaults.go:L978-1015 |
username | "" | Redis username. Folded into uri and cleared (same as addr). json:"-" does NOT apply to username; only password is marshal-suppressed. |
password | "" | Redis password. Folded into uri and cleared. Tagged json:"-" — never marshalled to JSON output. common/config.go:L386 |
db | 0 | Redis database index. Folded into uri path and cleared. Always appended as "/<db>" even for index 0. |
connPoolSize | 8 | Connection pool size. common/defaults.go:L955-957 |
initTimeout | 5s | common/defaults.go:L958-960 |
getTimeout | 1s | common/defaults.go:L961-963 |
setTimeout | 3s | common/defaults.go:L964-966 |
lockRetryInterval | 500ms | common/defaults.go:L967-969 |
tls | nil | Optional TLS config with sub-fields enabled (bool), certFile, keyFile, caFile, insecureSkipVerify. When tls.enabled=true and addr is set, SetDefaults sets the URI scheme to rediss://. When uri is provided with rediss:// scheme, tls.* fields are merged onto the baseline TLS config. data/redis.go:L149-179 |
gRPC connector (connector.grpc.*):
| Field | Auth-scope default | Notes |
|---|---|---|
servers | nil | Explicit server addresses. common/config.go:L356 |
bootstrap | "" | xDS bootstrap for server discovery. Appended to servers. data/grpc.go:L83-98 |
headers | nil | Static headers on all outbound gRPC requests. |
getTimeout | 100ms | Much tighter than other drivers. common/defaults.go:L927-929 |
Worked examples
All patterns below are distilled from real production fleets; comments explain the non-obvious choices.
1. Production database auth with fail-open and extended cache TTL. The customer-facing edge fleet uses PostgreSQL-backed dynamic API keys. The cache TTL was raised from 5s to 1m after a 2026-05-13 incident where transient pgbouncer disruption caused a reconnect storm across all 32 replicas simultaneously — lower query rate means transient errors are far less likely to cascade. Trade-off: revoked/disabled keys take up to 1m to stop working:
- type: database database: connector: id: auth-postgresql driver: postgresql postgresql: connectionUri: ${DATABASE_URL} # table defaults to "erpc_auth" at auth scope (differs from cache scope) cache: # Raised from 5s to 1m to cut the Postgres query rate ~12× and prevent # connector reconnect storms under transient pgbouncer disruptions. # Trade-off: revoked keys stay valid for up to 1 minute. ttl: 1m failOpen: # Keep traffic flowing under an emergency budget if the auth DB goes down; # the connector-down circuit-breaker limits real DB probes to ~1/second. enabled: true userId: emergency-failopen # Cap emergency traffic so a DB outage can't become a free-for-all rateLimitBudget: emergency-tier2. Admin endpoint secret. All production deployments share a single secret strategy on
the admin scope; the token is injected from an environment variable at deploy time. Note:
admin.auth being nil hard-errors the endpoint with no useful error code — always configure
it even in internal-only deployments:
# admin.auth — nil = admin always returns 500, not 401admin: auth: strategies: - type: secret secret: # admin endpoints only need identity; no rate-limit budget required value: ${ADMIN_SECRET_TOKEN}3. Multi-secret staging setup with per-identity budgets. The staging environment uses
multiple named secrets bound to the same rate-limit budget — useful for smoke-testing
different client identities without a database. Each id surfaces separately in Prometheus
labels so you can see per-client RPS in dashboards:
- type: secret secret: id: client-a value: ${CLIENT_A_TOKEN} # each secret gets its own budget so one client can't starve others rateLimitBudget: edge-tier-60krpm-total-unlimited-per-ip- type: secret secret: id: client-b value: ${CLIENT_B_TOKEN} rateLimitBudget: edge-tier-60krpm-total-unlimited-per-ip4. Network strategy as credential-less fallback for internal services. The staging
deployment allows all internal traffic via CIDR while still accepting named secrets for
external test callers. The network strategy must come first — it matches any request
with no credential, so placing it later would shadow secrets on credential-less paths:
# Place network strategy FIRST — it is the credential-less fallback;# any request without a token, JWT, or SIWE payload lands here.- type: network network: allowedCIDRs: ["10.0.0.0/8", "172.16.0.0/12"] allowLocalhost: true # ipAsUser: true gives each IP its own User.Id in Prometheus labels ipAsUser: true rateLimitBudget: internal-tier# External callers still use named secrets- type: secret secret: id: external-service value: ${EXTERNAL_SECRET_TOKEN} rateLimitBudget: external-tier5. JWT with per-user rate-limit tiers and key rotation. Embed the budget ID in the
token's rlm claim so a single strategy entry handles thousands of users. Rotate signing
keys by adding a new kid entry — old tokens continue to verify against the old key until
they naturally expire. Always set allowedAlgorithms to prevent algorithm-confusion attacks:
- type: jwt jwt: verificationKeys: # Add a new kid here when rotating; keep the old entry until tokens expire rsa-2024: "file:///etc/erpc/public-2024.pem" rsa-2025: "file:///etc/erpc/public-2025.pem" # ALWAYS restrict algorithms — omitting this allows algorithm-confusion attacks allowedAlgorithms: ["RS256"] allowedIssuers: ["https://auth.myapp.com"] allowedAudiences: ["https://rpc.myapp.com"] # Extract User.RateLimitBudget from this JWT claim; missing claim = no budget rateLimitBudgetClaimName: rlmRequest/response behavior
- A successful auth attaches
User{Id, RateLimitBudget}to the request;User.Idflows into log context and Prometheus labels on every downstream metric. [auth/registry.go:L74-76] ErrAuthUnauthorized→ HTTP 401. Carries{strategy}inDetails. [common/errors.go:L524-542]ErrAuthRateLimitRuleExceeded→ HTTP 429. Carries{projectId, strategy, budget, rule, userId, clientIp}inDetails. Therulevalue is formatted as"method:<rpc_method>". [common/errors.go:L545-568]- If no strategy's
Supportsreturns true (e.g., a JWT token arrives but onlynetworkis configured), the response is"no auth strategy matched"— not a per-strategy auth failure. [auth/registry.go:L46-96] - For admin scope, when
adminCfg != nilbutadminAuthRegistry == nil,AdminAuthenticatereturns a plainfmt.Errorfwith no typed error code → HTTP 200 (not 401/500). WhenadminCfgis nil, the path returnsErrAuthUnauthorized→ HTTP 401. [erpc/admin.go:L26-30] erpc_rate_limits_totalfires for auth-level budget exhaustion withorigin="auth"andauth="<type>:<index>"(e.g."secret:0","database:1"). [auth/authorizer.go:L137]ErrAuthRateLimitRuleExceededis auth-scope only — it fires when the auth-strategy-level rate-limit budget is exceeded. It does NOT fire for upstream/network rate-limit budgets (those produceErrProjectRateLimitRuleExceededorErrNetworkRateLimitRuleExceeded). Retryability:U:no(upstreams will not retry),N:yes(network propagates 429 to caller). [common/errors.go:L545-568]- gRPC required metadata:
x-erpc-project(required; missing →codes.InvalidArgument "missing metadata"),x-erpc-chain-id(required),x-erpc-architecture(optional, defaults to"evm"). [erpc/grpc_server.go:L138-145]
Best practices
- Use
databasefor user-facing APIs — dynamic key management without redeploys, per-user rate-limit budgets via the DB record'srateLimitBudgetfield, and the built-in cache means sub-millisecond auth on cache hits. - Always set
allowedAlgorithmsonjwt— leaving it nil accepts any algorithm, opening the door to algorithm-confusion attacks (e.g., RS256 public key forged as HS256 secret). - Always list at least one
allowedDomainsonsiwe— an empty or absent list silently rejects every SIWE request with no config-level indication. - Never set
rateLimitBudgeton ahealthCheck.authstrategy — the healthcheck auth registry is created with a nil rate-limiter registry; reaching the budget-acquisition path causes a nil pointer panic. - Enable
failOpenwith a capped emergency budget ondatabase— a DB outage should not make your RPC endpoint unreachable; the circuit-breaker bounds probe load to ~1 query/second per replica. - DynamoDB
lockRetryIntervaldefaults to0— zero means busy-spin retries during lock contention; set to100msor higher in production to avoid rapid API calls and unexpected cost. - Use
secretonly for private, server-side callers — the plain string comparison is not timing-safe; for browser-reachable endpoints, preferjwtordatabase.
Edge cases & gotchas
- No strategies = allow-all.
AuthRegistry.Authenticatereturnsnil, nilwhen the strategy list is empty. All requests pass. Auth is opt-in. - network strategy is the credential-less fallback. Requests without any recognizable credential get
AuthTypeNetwork. If no network strategy is configured and only asecretstrategy exists, credential-less requests get "no auth strategy matched", not "invalid secret". - secret and database both consume
AuthTypeSecret. If both are configured, declaration order determines which runs first. To use only database, omit the secret strategy. - JWT
allowedAudiencesonly supports scalaraud. The implementation castsclaims["aud"]tostring. Tokens with"aud": ["a","b"](array form) always fail the cast and are rejected even if a matching audience is in the array. - SIWE
allowedDomainsnil/empty = deny-all. Visual appearance ofsiwe: {}gives no indication it is a blanket deny. network.trustedProxiesis an intentional no-op. Configureserver.trustedIPForwarders/server.trustedIPHeadersinstead.- Healthcheck auth cannot use
rateLimitBudget. The healthcheck auth registry is created withrateLimitersRegistry = nil. Setting a budget on a healthcheck strategy reaches a nil pointer dereference atacquireRateLimitPermit. - secret uses non-constant-time comparison. Plain
!=is timing-side-channel vulnerable for public internet exposures. - Database negative cache TTL is hardcoded at 5 seconds. Not configurable. Re-enabled keys are rejected for up to 5 seconds.
- Connector-down probe is per-process. One probe/second per replica, no cross-replica coordination.
- type inference conflict. Setting multiple sub-config blocks (e.g. both
secret:andjwt:) in one strategy entry causes the last-evaluated block to silently overwritetype. Evaluation order:secret → database → jwt → siwe. Never set multiple sub-config blocks in one strategy entry. Authorization: Basicusername is silently discarded. Only the password field is used as the secret value.alice:mysecretandbob:mysecretare treated identically.- JWT requires keys. Configure
verificationKeysand/orverificationJwksUrl. With neither, startup validation fails. An empty resolved key map rejects every JWT (deny-all, not allow-all). - SIWE requires both signature and message together. Missing either one causes silent fallthrough to network-strategy payload extraction.
?token=query param is deprecated. Alias for?secret=. Continues to work but may be removed; use?secret=orX-ERPC-Secret-Tokenheader in new implementations.- Redis
addr/username/password/dbare cleared afterSetDefaults. After initialization onlyuriis set. Config exports show only the URI (with credentials URL-encoded in it). - DynamoDB
lockRetryIntervaldefaults to0. Zero = busy-spin during lock contention. Set to a non-zero value (e.g.100ms) in production. - database.cache, database.retry, and database.failOpen are always auto-created. Caching is always on with 1h TTL unless
ttlis explicitly changed. There is no config to disable caching by omitting the block. type: "network"auto-creates thenetwork {}sub-struct; the converse is asymmetric. Adding anetwork:block does NOT overwritetype— unlikesecret/database/jwt/siwewhich force-overwritetypewhen their sub-block is present.- When
admin.authis nil the admin endpoint hard-errors with HTTP 500.AdminAuthenticatereturns a plainfmt.Errorf(no typed error code) → HTTP 200 wire, not 401. Configureadmin.authto protect it properly. databasestrategy also acceptsAuthTypeDatabaseinSupports. The enum valueAuthTypeDatabaseexists but is never produced by any current HTTP or gRPC payload extractor. It is reserved for future or programmatic injection. In practice, database auth is triggered viaAuthTypeSecretcredentials (header/query token). [auth/strategy_database.go:L118-120]- Singleflight scope is per API key. The singleflight group inside
DatabaseStrategyuses the raw API key string as the deduplication key. Concurrent requests with the same API key during a cache miss are coalesced into a single DB lookup. Requests with different API keys run in parallel. [auth/strategy_database.go:L173-178] AuthConfigis shared across all three scopes. The sameAuthConfigtype is used forprojects[*].auth,admin.auth, andhealthCheck.auth. All five strategy types can be configured in any scope. The only scope-specific hazard ishealthCheck.auth— the healthcheck auth registry is created with a nilrateLimitersRegistry, so settingrateLimitBudgeton any healthcheck strategy causes a nil pointer panic.- Redis
addr/username/password/dbare cleared afterSetDefaults. After initialization, onlyuriis set; all discrete fields are zeroed. Config exports show only the URI (with credentials URL-encoded in it). Becausepasswordhasjson:"-", a JSON export shows the URI but not the password field. [common/defaults.go:L1012-1015] - Admin auth registry DOES support rate-limit budgets. Unlike the healthcheck scope, the admin
AuthRegistryis created with the samerateLimitersRegistryas projects. Rate-limit budgets on admin auth strategies are applied when configured. verificationJwksUrlmust be absolute HTTP(S). Non-empty values are parsed at startup; scheme must behttporhttpsand a host is required. Scheme-less hosts,file://, and other schemes fail validation even when staticverificationKeysare also set. [common/validation.go]
Observability
| Metric | Type | Labels | When it fires |
|---|---|---|---|
erpc_auth_failed_total | counter | project, network, strategy, reason, agent_name | Database strategy only. Reason values: missing_secret, empty_secret, cached_unknown_api_key, db_fail_open_fast_path, db_not_ready, db_timeout, db_connection, db_query_error, invalid_api_key, disabled_key, db_record_parse_error, db_record_missing_user_id, internal_error. |
erpc_rate_limits_total | counter | project, network, vendor, upstream, category, finality, user, agent_name, budget, scope, auth, origin | When auth-level rate-limit budget is exhausted. origin="auth", auth="<type>:<index>" (e.g. "secret:0", "database:1"). |
Log messages (database strategy):
| Level | Message | Key fields |
|---|---|---|
warn | "database connector marked DOWN; subsequent requests will fast-path to fail-open until next probe succeeds" | connectorId |
warn | "database connector marked UP; resuming normal auth flow" | connectorId |
warn | "singleflight error; fail-open enabled, granting emergency user" | userId, err |
warn | "database authentication lookup failed; retrying" | driver, connectorId, attempt, backoff, err |
error | "database query failed during authentication" | apiKey, driver, connectorId, err |
error | "auth DB error; fail-open enabled, granting emergency user" | userId |
info | "initialized API key cache for database authentication strategy" | ttl, negTtl, maxSize, maxCost, numCounters |
debug | "API key found in cache" / "not found in cache" | apiKey |
debug | "API key found in negative cache" | apiKey |
debug | "cached API key data" | apiKey, ttl |
debug | "user authenticated successfully" | apiKey, userId, budget |
debug | "invalidated API key cache entry" | apiKey |
No dedicated auth trace spans — auth runs inline inside the HTTP handler goroutine within the OTel request span.
Source code entry points
auth/registry.go:L46-L96(opens in a new tab) —AuthRegistry.Authenticate: first-win loop, error accumulationauth/authorizer.go:L83-L157(opens in a new tab) —shouldApplyToMethod,acquireRateLimitPermit, budget cascadeauth/http.go:L13-L88(opens in a new tab) —NewPayloadFromHttp: credential extraction priority orderauth/strategy_database.go:L271-L357(opens in a new tab) — connector-down circuit-breaker, probe election, fail-openauth/strategy_jwt.go:L100-L116(opens in a new tab) — JWT key selection (kid lookup + type-compatibility scan)auth/strategy_siwe.go:L22-L67(opens in a new tab) — SIWE signature verification + domain allowlistauth/strategy_network.go:L47-L93(opens in a new tab) — NetworkStrategy: localhost/IP/CIDR checks,ipAsUsermodecommon/config.go:L2433-L2534(opens in a new tab) — all auth config type declarationscommon/defaults.go:L2611-L2761(opens in a new tab) — SetDefaults for all auth typesauth/strategy_database_test.go(opens in a new tab) — circuit-breaker, probe election, fail-open,classifyDbErrorlabel tests
Related pages
- Rate limiters — define the budgets that auth strategies assign to each identity.
- Projects — auth lives at
projects[*].auth; project-level config context. - Use cases: Multi-tenant RPC — end-to-end example combining auth + rate limiters.