Transport-neutral validation of API keys and other pre-shared credentials against an in-memory store keyed by HMAC-SHA256 digests.
- Overview
- Architecture
- Package Map
- Quick Start
- Configuration
- Go API
- Storage and Timing
- Stable Cross-Process Digests
- Rate Limiting
- Errors
- Metrics
- Transport Integration
- Security Notes
The auth/static package validates pre-shared credentials (API keys, opaque tokens, service keys) without an identity provider. It
exposes a small TokenStore interface and a default InMemoryStore that keys tokens by their
HMAC-SHA256 digest, never by plaintext.
It is transport-neutral: the gRPC interceptor at transport/grpc/interceptors/auth/static and the HTTP middleware at
transport/http/server/middlewares/auth/static are thin adapters over this package (see Transport Integration).
Two decorators compose with the core store:
RateLimitedStore— gates validation through a caller-suppliedRateLimiterto slow brute force.Metrics— records validation outcomes, latency, and the active-token count.
graph TB
subgraph "Transport adapters"
GRPC[gRPC interceptor<br/>.../auth/static]
HTTP[HTTP middleware<br/>.../auth/static]
end
subgraph "auth/static"
TS[TokenStore<br/>interface]
RLS[RateLimitedStore<br/>decorator]
IMS[InMemoryStore<br/>HMAC-SHA256 digests]
MET[Metrics<br/>nil = no-op]
end
subgraph "Caller-supplied"
RL[RateLimiter<br/>e.g. data/limiters/tokenbucket]
KF[KeyFunc<br/>IP / tenant / key-prefix]
end
GRPC --> TS
HTTP --> TS
TS -.implemented by.-> RLS
TS -.implemented by.-> IMS
RLS --> IMS
RLS --> RL
RLS --> KF
IMS --> MET
sequenceDiagram
participant App
participant RLS as RateLimitedStore
participant L as RateLimiter
participant S as InMemoryStore
App->>RLS: Validate(ctx, token)
RLS->>RLS: key = keyFn(ctx)
alt key != "" and !Allow(key)
L-->>RLS: deny
RLS-->>App: ErrRateLimited
else allowed (or empty key)
RLS->>S: Validate(ctx, token)
Note over S: HMAC-SHA256(token) → single map probe
alt hit
S-->>RLS: data
RLS-->>App: data
else miss
S-->>RLS: ErrTokenInvalid / ErrTokenEmpty
RLS->>L: RecordFailure(key)
RLS-->>App: error
end
end
| File | Responsibility |
|---|---|
store.go |
TokenStore, InMemoryStore, HMAC digesting, Add/Remove/TokenCount |
ratelimit.go |
RateLimiter, KeyFunc, RateLimitedStore decorator |
metrics.go |
Metrics (validations, active tokens, latency) |
errors.go |
Sentinel errors |
options.go / options_gen.go / options_manual.go |
Functional options |
example_test.go |
Runnable examples |
store := static.NewInMemoryStore(
static.WithInitialTokens(map[string]any{
"sk_live_xxx": UserInfo{ID: "u1", Role: "admin"},
"sk_test_xxx": UserInfo{ID: "u2", Role: "viewer"},
}),
)
data, err := store.Validate(ctx, token)
switch {
case errors.Is(err, static.ErrTokenEmpty):
// no credential supplied
case errors.Is(err, static.ErrTokenInvalid):
// reject
case err != nil:
// other
default:
user := data.(UserInfo)
_ = user
}Tokens can be managed at runtime:
store.AddToken("freshly-issued", UserInfo{ID: "u3"})
store.RemoveToken("compromised")
n := store.TokenCount()| Option | Default | Description |
|---|---|---|
WithInitialTokens |
empty | Token → data pairs seeded at construction time |
WithMetrics |
nil (no-op) | *Metrics recording validations and the active-token count |
WithHMACKey |
per-instance 32-byte random key | Stable digest key for cross-process consistency (minimum 16 bytes; shorter values are ignored) |
WithHMACKey enforces a MinHMACKeyBytes (16) floor — NIST SP 800-107 recommends a key at least as long as the digest size, which for
HMAC-SHA256 is 32 bytes. Keys below the floor are silently ignored and the random default is used.
type TokenStore interface {
// Validate returns the data associated with token, or an error wrapping
// ErrTokenInvalid / ErrTokenEmpty if the token is rejected.
Validate(ctx context.Context, token string) (any, error)
}Both InMemoryStore and RateLimitedStore implement TokenStore, so they compose transparently and a transport adapter can take either.
func NewInMemoryStore(opt ...Option) *InMemoryStore
func (s *InMemoryStore) Validate(ctx context.Context, token string) (any, error)
func (s *InMemoryStore) AddToken(token string, data any)
func (s *InMemoryStore) RemoveToken(token string)
func (s *InMemoryStore) TokenCount() intThe associated data is any, so a caller stores whatever principal model fits — a user struct, a role set, a tenant id. The zero value is
not usable; always construct with NewInMemoryStore.
InMemoryStore keys tokens by their HMAC-SHA256 digest. The plaintext token is hashed at insertion time and discarded; only digests are
retained in memory. A lookup is a single map probe (no linear scan, no early-break loop), so timing depends on token length only, not on
token position or membership. The HMAC instance is recycled through a sync.Pool, so a steady-state lookup allocates only the digest byte
slice.
The default HMAC key is generated from crypto/rand once per InMemoryStore, so digests are not valid across restarts or across
processes. When several processes must agree on the storage digest of a token (a shared cache, distributed rate-limit keying), pass a
stable key:
store := static.NewInMemoryStore(static.WithHMACKey(secret)) // secret >= 16 bytesRateLimitedStore wraps any TokenStore and consults a caller-supplied RateLimiter before delegating. The package ships no
limiter implementation; wire one from data/limiters/tokenbucket, a Redis-backed limiter, or any other source through the interface.
limiter := myLimiter // implements static.RateLimiter
store := static.NewRateLimitedStore(inner, limiter, func(ctx context.Context) string {
return extractClientIP(ctx)
})NewRateLimitedStore panics if store, limiter, or keyFn is nil; none has a safe default.
The RateLimiter contract is failure-only:
type RateLimiter interface {
Allow(ctx context.Context, key string) bool // pure check — MUST NOT consume budget
RecordFailure(ctx context.Context, key string) // the only path that debits the budget
}Allow is a pure check that must not consume budget by itself; RecordFailure is the only path that debits the per-key counter.
Successful validations never touch the limiter. This shape defeats the success-resets-counter brute-force bypass: if a success could reset
(or even refund) the budget, an attacker holding a single valid token could interleave one valid attempt with N invalid attempts and the
invalid attempts would never accumulate.
Because the decorator never refunds, the limiter must self-decay (token-bucket refill, sliding window, TTL'd counter). A monotonic counter that never decays will eventually lock legitimate clients out of their own tokens.
KeyFunc derives the rate-limit key for a request — typically the client IP, an API-key prefix, or a tenant id.
- Returning an empty string skips the rate-limit check for that request (useful for trusted internal traffic).
- Returning the same non-empty key for every request is forbidden by contract: the limiter then becomes one global bucket and a single
attacker can trip
ErrRateLimitedfor every legitimate user (DoS amplification). Partition by an attribute correlated with the suspected attacker (IP, ASN, account, tenant), never a constant.
The key is never embedded in the returned error text, keeping client identifiers out of logs and gRPC/HTTP error payloads.
| Sentinel | Returned when |
|---|---|
ErrTokenInvalid |
Token is non-empty but not registered with the store |
ErrTokenEmpty |
Token is the empty string |
ErrRateLimited |
RateLimitedStore rejects a request via its RateLimiter |
Compare with errors.Is, not ==: the transport adapters wrap these with additional context while preserving the cause.
Pass a *Metrics via WithMetrics. A nil *Metrics is a valid no-op receiver. Construct one with NewMetrics(collector, subsystem); an
empty subsystem falls back to DefaultMetricsSubsystem (auth_static).
| Metric | Type | Labels | Description |
|---|---|---|---|
auth_static_validations_total |
Counter | status |
Token validation attempts |
auth_static_tokens_active |
Gauge | — | Current number of active tokens in the store |
auth_static_validation_duration_seconds |
Histogram | — | Validation duration |
status is success or failure. tokens_active is republished on every AddToken / RemoveToken and on construction.
Both adapters re-export the auth/static types and options verbatim and add only an AuthFunc that maps this package's sentinels into the
transport's error model. Configure the store with auth/static; the adapter wires it into the interceptor or middleware.
transport/grpc/interceptors/auth/static provides AuthFunc, translating sentinels into gRPC status codes:
auth/static error |
gRPC status code |
|---|---|
ErrTokenInvalid |
codes.Unauthenticated |
ErrTokenEmpty |
codes.Unauthenticated |
ErrRateLimited |
codes.ResourceExhausted |
| anything else | codes.Internal |
store := static.NewInMemoryStore(static.WithInitialTokens(tokens))
interceptor := auth.ServerInterceptor(
auth.WithAuthFn(staticgrpc.AuthFunc(store)),
)transport/http/server/middlewares/auth/static provides AuthFunc, wrapping store errors with auth.ErrUnauthorized while preserving the
original cause for logs and custom auth.ErrorHandler implementations. Branch with errors.Is(err, auth.ErrUnauthorized) or
errors.Is(err, static.ErrTokenInvalid).
- Plaintext tokens are hashed and discarded at insertion; only HMAC-SHA256 digests live in memory, and lookups are constant-time with respect to membership and position (timing depends on token length only).
- The default HMAC key is per-instance and ephemeral; pass
WithHMACKeyonly when cross-process digest stability is genuinely needed. - Always transport tokens over TLS and rotate them on a schedule. Apply brute-force protection at a higher layer via
RateLimitedStoreor a transport-level rate limiter; the store itself does not throttle.
See the package README for the file-level reference, and docs/auth/oidc.md /
docs/auth/selfjwt.md for token-based authentication.