Skip to content

Configure API keys and bearer tokens

Use these tasks to protect Echo routes with application-issued keys or issuer-signed access tokens. First install Echo Pro. The examples use public imports from the installed module. Run the examples from its source directory:

Terminal window
go test -run Example ./auth/...

The authentication overview explains credential selection and JWKS failure policy. The reference lists fields and defaults.

Load a pepper ring from your runtime secret manager. Use a subject that never changes, such as invoice-worker. Set KeyOptions.TenantRestriction for a single-tenant service. Retain StoredKey; deliver the returned credential once. This example prints only lengths, the pepper version and tenant. It does not print the credential.

func ExampleIssueKey() {
pepper := make([]byte, 32)
if _, err := rand.Read(pepper); err != nil {
panic(err)
}
token, record, err := auth.IssueKey("prod", auth.PepperRing{{
Version: 7,
Key: pepper,
}}, "billing-worker", auth.KeyOptions{Kind: auth.Service, TenantRestriction: "acme"})
if err != nil {
panic(err)
} // Deliver token once through a secure channel; persist only record.
fmt.Println(
len(token),
len(record.ID),
record.PepperVersion,
record.TenantRestriction,
)
// Output: 71 22 7 acme
}

For revocation, persist a nonzero StoredKey.RevokedAt through your store implementation. Keep the old pepper while nonrevoked records still reference its version. Use a bounded memory store only for disposable state. Construct auth.APIKey with a namespace distinct from other schemes and an explicit header such as X-API-Key.

auth.KeyStore requires one method: Lookup(context.Context, string) (auth.StoredKey, error). The string is the key ID. Look up the current record, including revocation, on every call. Missing keys return a zero record, an error, or both. Lookups must honor cancellation and support concurrent calls. Keep peppers separate from the database.

This compiled example reads JSON records through database/sql. Your application supplies the opened pool, driver and table migration. Persist the StoredKey returned by IssueKey as the row’s JSON record. Update RevokedAt in that row to revoke it. The example compiles without opening a database; test the query against your own table before serving.

// exampleSQLKeys reads the latest record on every request. The application owns
// the pool and an api_keys table with id text and record jsonb columns.
// The record stores auth.StoredKey as JSON, including current RevokedAt.
type exampleSQLKeys struct{ db *sql.DB }
func (s exampleSQLKeys) Lookup(ctx context.Context, id string) (auth.StoredKey, error) {
var raw []byte
err := s.db.QueryRowContext(ctx,
"SELECT record FROM api_keys WHERE id = $1", id,
).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return auth.StoredKey{}, nil
}
if err != nil {
return auth.StoredKey{}, err
}
var record auth.StoredKey
if err := json.Unmarshal(raw, &record); err != nil {
return auth.StoredKey{}, err
}
return record, nil
}
var _ auth.KeyStore = exampleSQLKeys{}
// ExampleKeyStore compiles persistent lookup wiring. Supply an opened pool and
// apply the application's table migration before serving; no database runs here.
func ExampleKeyStore() {
var db *sql.DB // Your application's opened, caller-owned pool.
scheme := auth.APIKey{Store: exampleSQLKeys{db: db}}
// Also set Namespace, Prefix and Peppers before building auth.Config.
_ = scheme
}

Set the exact issuer, accepted API audiences and a configured JWKS URL. The complete example uses a local HTTPS fixture with one Ed25519 key. It signs an access token, passes it through Echo and prints 200 employees:alice tenant=acme.

func ExampleNewBearer() {
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
body, err := json.Marshal(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{
Key: public,
KeyID: "signing-1",
Use: "sig",
Algorithm: "EdDSA",
}}})
if err != nil {
panic(err)
}
issuer := httptest.NewTLSServer(http.HandlerFunc(func(
w http.ResponseWriter,
_ *http.Request,
) {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(body); err != nil {
panic(err)
}
}))
defer issuer.Close()
ctx := context.Background()
bearer, err := auth.NewBearer(ctx, auth.BearerConfig{
Namespace: "employees", Issuer: issuer.URL, Audiences: []string{"invoice-api"},
JWKSURL: issuer.URL + "/jwks", Client: issuer.Client(),
TenantClaim: "tenant_id", TrustTenantClaim: true, ClaimAllowlist: []string{
"department",
},
})
if err != nil {
panic(err)
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: private},
(&jose.SignerOptions{}).WithType("at+jwt").WithHeader("kid", "signing-1"))
if err != nil {
panic(err)
}
claims, err := json.Marshal(map[string]any{
"iss": issuer.URL, "sub": "alice", "aud": "invoice-api", "exp": time.Now().Add(
time.Hour,
).Unix(),
"scope": "invoice:read", "tenant_id": "acme", "department": "billing",
})
if err != nil {
panic(err)
}
signed, err := signer.Sign(claims)
if err != nil {
panic(err)
}
token, err := signed.CompactSerialize()
if err != nil {
panic(err)
}
middleware, err := (auth.Config{Schemes: map[string]auth.Scheme{"oauth": bearer},
Fallback: auth.Require(map[string][]string{
"oauth": {"invoice:read"},
})}).ToMiddleware()
if err != nil {
panic(err)
}
e := echo.New()
e.HTTPErrorHandler, err = (echoadapter.ErrorHandlerConfig{}).ToErrorHandler(
e.HTTPErrorHandler,
)
if err != nil {
panic(err)
}
e.GET("/invoices", func(c *echo.Context) error {
principal, _ := auth.FromEcho(c)
return c.String(
http.StatusOK,
principal.ID()+" tenant="+principal.TenantRestriction(),
)
}, middleware)
r := httptest.NewRequestWithContext(ctx, http.MethodGet, "/invoices", nil)
r.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
fmt.Println(w.Code, w.Body.String())
// Output: 200 employees:alice tenant=acme
}

Replace the fixture issuer and client with your trusted issuer origin and application client. Keep certificate verification enabled. Use DiscoveryURL instead of JWKSURL to discover the key location. A nonempty AllowedJWKSHosts must list every permitted discovered host, including the discovery host when needed. Set Algorithms only to the asymmetric algorithms your issuer uses. Retain only claims the application needs in ClaimAllowlist. Enable TrustTenantClaim only for an issuer authorized to restrict your application’s tenants.

Accept either an API key or a JWT on one route

Section titled “Accept either an API key or a JWT on one route”

Pass two single-scheme maps to auth.Require for OR. This route accepts either the configured API key or the verified bearer token, with invoice:read scope. Supply the API key and constructed bearer verifier from the preceding tasks, with distinct namespaces and credential headers. Send one credential mechanism per request.

// MountEitherCredential adds one route accepting either configured scheme.
func MountEitherCredential(e *echo.Echo, key auth.APIKey, jwt *auth.Bearer) error {
guard, err := (auth.Config{
Schemes: map[string]auth.Scheme{"key": key, "jwt": jwt},
Fallback: auth.Require(
map[string][]string{"key": {"invoice:read"}},
map[string][]string{"jwt": {"invoice:read"}},
),
}).ToMiddleware()
if err != nil {
return err
}
e.GET("/invoices", func(c *echo.Context) error { return c.NoContent(204) }, guard)
return nil
}

One map containing both schemes means AND and is rejected by ToMiddleware with a configuration error.

Authenticate and select tenants before quota

Section titled “Authenticate and select tenants before quota”

Build authbind.Middleware(authCfg, tenantCfg) first. Construct authbind.Quota() for the quota callback. Register the guard before quota on the same Echo group. The combined program’s run function also installs the problem error handler and closes limiter-owned work during shutdown.

authCfg := auth.Config{
Schemes: map[string]auth.Scheme{"key": auth.APIKey{
Namespace: "app",
Prefix: "demo",
Peppers: ring,
Store: keys,
Header: "X-API-Key",
}},
Fallback: auth.Require(map[string][]string{"key": {"invoice:read"}}),
}
tenantPath, err := tenant.FromPathParam("tenant")
if err != nil {
return err
}
guard, err := authbind.Middleware(authCfg, tenant.Config{
Selectors: []tenant.Selector{tenant.FromCredential(), tenantPath}, Required: true,
})
if err != nil {
return err
}
lim, err := quota.New(quota.Config{
Store: quota.NewMemoryStore(quota.MemoryConfig{MaxKeys: 100}),
Plans: []quota.Plan{{ID: "free", Limits: []quota.Limit{
quota.PerDay("requests", 3),
}}},
})
if err != nil {
return err
}
defer func() {
closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
result = errors.Join(result, lim.Close(closeCtx))
}()
identity, err := authbind.Quota()
if err != nil {
return err
}
quotaMW, err := (quota.MiddlewareConfig{
Limiter: lim,
Identity: identity,
}).ToMiddleware()
if err != nil {
return err
}
api := e.Group("/v1/tenants/:tenant", guard, quotaMW)
api.GET("/invoices", func(c *echo.Context) error {
id, _ := tenant.From(c.Request().Context())
return c.JSON(http.StatusOK, map[string]any{
"tenant": id,
"invoices": []string{"inv-1"},
})
})

Authenticate and select the tenant before a quota usage route too. For public operations, set Fallback explicitly and decide whether quota applies. Do not derive a quota tenant from an unverified request header.

Install echoadapter.ErrorHandlerConfig{}.ToErrorHandler as Echo’s HTTP error handler, retaining the previous handler as fallback. The complete API-key example does this and checks media types. Missing and invalid credentials return 401 unauthenticated. Insufficient scopes return 403 forbidden. Read WWW-Authenticate before deciding whether the client needs a new token or a different grant.

Test an absent credential, a malformed credential and a valid credential without the required scope. Test repeated credential fields and two credential mechanisms on one request. Verify that rejected requests never reach quota or the business handler. These examples use local fixtures without an external token issuer. Test discovery and key rotation against your deployed issuer.

Use this when the MCP Go SDK has already verified the bearer token.

MCPMapping.Principal accepts verified SDK TokenInfo. It does not authenticate tool arguments. Configure the namespace and the trusted extra-field name used for the tenant restriction. Keep this namespace distinct from unrelated HTTP schemes unless they intentionally produce the same principal IDs.

func ExampleMCPMapping_Principal() {
m := authbind.MCPMapping{Namespace: "tools", ExtraTenantKey: "verified_tenant"}
info := &mcpauth.TokenInfo{UserID: "alice", Expiration: time.Now().Add(
time.Hour,
), Extra: map[string]any{
"verified_tenant": "acme",
}}
p, err := m.Principal(info)
if err != nil {
panic(err)
}
fmt.Println(p.ID(), p.TenantRestriction())
// Output: tools:alice acme
}