API Keys¶
API keys provide server-to-server authentication for programmatic access.
Quick Start¶
SystemForge provides a complete API key management system:
import (
"github.com/plexusone/systemforge/identity/apikey"
)
// Create service with your store implementation
service := apikey.NewService(apikey.ServiceConfig{
Store: myStore, // Your store implementation
Prefix: "myapp", // Key prefix (default: "cf")
AllowedScopes: []string{"read:*", "write:*"},
})
// Generate a new key
result, err := service.Create(ctx, apikey.CreateKeyRequest{
Name: "CI/CD Pipeline",
OwnerID: userID,
Scopes: []string{"read:deployments", "write:deployments"},
})
// result.Key = "myapp_live_abc123..._xyz789..." (show once)
// result.APIKey = metadata (store in UI)
// Validate incoming key
apiKey, err := service.Validate(ctx, rawKey)
if err != nil {
// Invalid, expired, or revoked
}
// Check scope
if !apiKey.HasScope("write:deployments") {
// Forbidden
}
Storage¶
The apikey.Service above works against a Store you provide. SystemForge does not ship an API key table of its own; choose one of two backing stores:
Principal Credentials¶
Store keys as sf_credentials rows of type api_key, owned by a principal. The identity/credential service creates, validates, lists, and revokes them, storing only a hash and a display prefix:
import "github.com/plexusone/systemforge/identity/credential"
creds := credential.NewService(client)
key, err := creds.CreateAPIKey(ctx, credential.CreateAPIKeyInput{
PrincipalID: principalID,
Name: "CI/CD Pipeline",
Scopes: []string{"read:deployments"},
})
// key.PlainKey is shown once; key.Credential is the stored record.
cred, err := creds.ValidateAPIKey(ctx, rawKey)
Use this when keys belong to a principal (a human or a service) and should share its lifecycle.
Application Table¶
Keep keys in your own table by adding the API-key mixin to an Ent schema in your application, then implementing apikey.EntClientInterface over your generated client:
import "github.com/plexusone/systemforge/identity/ent/mixin"
func (APIKey) Mixin() []ent.Mixin {
return []ent.Mixin{
mixin.APIKey{},
}
}
Use this when keys need application-specific fields or relationships.
Key Format¶
Keys generated by apikey.Service have the form <prefix>_<environment>_<random>_<random>, for example sf_live_4Fh2kQ9x_7K3mN9pQrS2tUvW4xYz6. The prefix defaults to sf and is set with ServiceConfig.Prefix; the environment is live or test. Only a SHA-256 hash of the full key is stored, along with the visible <prefix>_<environment>_<random> part for identification.
Creating API Keys¶
result, err := service.Create(ctx, apikey.CreateKeyRequest{
Name: "CI/CD Pipeline",
OwnerID: ownerID,
Scopes: []string{"read:deployments", "write:deployments"},
})
// result.Key is the full key: return it to the caller once, never store it.
// result.APIKey is the stored metadata.
CreateKeyRequest also accepts an organization, an expiration, and an environment.
Validating API Keys¶
apiKey, err := service.Validate(ctx, rawKey)
if err != nil {
// Malformed, unknown, expired, or revoked
}
// Or require a scope in the same call.
apiKey, err = service.ValidateWithScope(ctx, rawKey, "write:deployments")
Scope Checking¶
APIKey matches scopes exactly and honors :* wildcards (read:* grants read:deployments):
if !apiKey.HasScope("write:deployments") {
return ErrForbidden
}
apiKey.HasAnyScope("read:deployments", "read:builds")
apiKey.HasAllScopes("read:deployments", "write:deployments")
Managing API Keys¶
keys, err := service.List(ctx, ownerID) // by owner
keys, err = service.ListByOrganization(ctx, orgID) // by organization
err = service.Revoke(ctx, keyID, "rotated") // keeps the record
err = service.Delete(ctx, keyID) // removes it
Security Best Practices¶
- Never log API keys - Only log the prefix for debugging
- Set expirations - Use short-lived keys when possible
- Scope narrowly - Grant minimum required permissions
- Monitor usage - Track
last_used_atfor anomaly detection - Allow revocation - Provide UI for users to revoke keys
- Rate limit - Protect against brute force attacks
Usage Example¶
# Using an API key
curl https://api.example.com/v1/deployments \
-H "Authorization: Bearer sf_live_4Fh2kQ9x_7K3mN9pQrS2tUvW4xYz6"
Ent Store Implementation¶
SystemForge provides an Ent-backed store implementation for production use.
Step 1: Add Schema Using Mixin¶
// internal/ent/schema/api_key.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"github.com/plexusone/systemforge/identity/ent/mixin"
)
// APIKey holds the schema definition.
type APIKey struct {
ent.Schema
}
// Mixin of the APIKey.
func (APIKey) Mixin() []ent.Mixin {
return []ent.Mixin{
mixin.APIKey{},
}
}
// Edges of the APIKey.
func (APIKey) Edges() []ent.Edge {
return []ent.Edge{
edge.From("owner", User.Type).
Ref("api_keys").
Field("owner_id").
Required().
Unique(),
}
}
Step 2: Implement Client Interface¶
// internal/auth/apikey_store.go
package auth
import (
"context"
"time"
"github.com/google/uuid"
"github.com/plexusone/systemforge/identity/apikey"
"myapp/internal/ent"
entapikey "myapp/internal/ent/apikey"
)
// APIKeyClientWrapper implements apikey.EntClientInterface.
type APIKeyClientWrapper struct {
client *ent.Client
}
func NewAPIKeyClientWrapper(client *ent.Client) *APIKeyClientWrapper {
return &APIKeyClientWrapper{client: client}
}
func (w *APIKeyClientWrapper) CreateAPIKey(ctx context.Context, key *apikey.APIKey, keyHash string) error {
create := w.client.APIKey.Create().
SetID(key.ID).
SetName(key.Name).
SetPrefix(key.Prefix).
SetKeyHash(keyHash).
SetOwnerID(key.OwnerID).
SetScopes(key.Scopes).
SetEnvironment(entapikey.Environment(key.Environment)).
SetCreatedAt(key.CreatedAt).
SetUpdatedAt(key.UpdatedAt)
if key.OrganizationID != nil {
create = create.SetOrganizationID(*key.OrganizationID)
}
if key.Description != "" {
create = create.SetDescription(key.Description)
}
if key.ExpiresAt != nil {
create = create.SetExpiresAt(*key.ExpiresAt)
}
if key.Metadata != nil {
create = create.SetMetadata(key.Metadata)
}
_, err := create.Save(ctx)
return err
}
func (w *APIKeyClientWrapper) GetAPIKeyByPrefix(ctx context.Context, prefix string) (*apikey.APIKey, string, error) {
k, err := w.client.APIKey.Query().
Where(entapikey.PrefixEQ(prefix)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, "", apikey.ErrKeyNotFound
}
return nil, "", err
}
return toAPIKey(k), k.KeyHash, nil
}
func (w *APIKeyClientWrapper) GetAPIKeyByID(ctx context.Context, id uuid.UUID) (*apikey.APIKey, error) {
k, err := w.client.APIKey.Get(ctx, id)
if err != nil {
if ent.IsNotFound(err) {
return nil, apikey.ErrKeyNotFound
}
return nil, err
}
return toAPIKey(k), nil
}
func (w *APIKeyClientWrapper) ListAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID) ([]*apikey.APIKey, error) {
keys, err := w.client.APIKey.Query().
Where(entapikey.OwnerIDEQ(ownerID)).
Order(ent.Desc(entapikey.FieldCreatedAt)).
All(ctx)
if err != nil {
return nil, err
}
result := make([]*apikey.APIKey, len(keys))
for i, k := range keys {
result[i] = toAPIKey(k)
}
return result, nil
}
func (w *APIKeyClientWrapper) ListAPIKeysByOrganization(ctx context.Context, orgID uuid.UUID) ([]*apikey.APIKey, error) {
keys, err := w.client.APIKey.Query().
Where(entapikey.OrganizationIDEQ(orgID)).
Order(ent.Desc(entapikey.FieldCreatedAt)).
All(ctx)
if err != nil {
return nil, err
}
result := make([]*apikey.APIKey, len(keys))
for i, k := range keys {
result[i] = toAPIKey(k)
}
return result, nil
}
func (w *APIKeyClientWrapper) UpdateAPIKey(ctx context.Context, key *apikey.APIKey) error {
update := w.client.APIKey.UpdateOneID(key.ID).
SetName(key.Name).
SetScopes(key.Scopes).
SetRevoked(key.Revoked).
SetUpdatedAt(key.UpdatedAt)
if key.Description != "" {
update = update.SetDescription(key.Description)
}
if key.RevokedAt != nil {
update = update.SetRevokedAt(*key.RevokedAt)
}
if key.RevokedReason != "" {
update = update.SetRevokedReason(key.RevokedReason)
}
if key.Metadata != nil {
update = update.SetMetadata(key.Metadata)
}
_, err := update.Save(ctx)
return err
}
func (w *APIKeyClientWrapper) DeleteAPIKey(ctx context.Context, id uuid.UUID) error {
return w.client.APIKey.DeleteOneID(id).Exec(ctx)
}
func (w *APIKeyClientWrapper) UpdateAPIKeyLastUsed(ctx context.Context, id uuid.UUID, ip string) error {
now := time.Now()
_, err := w.client.APIKey.UpdateOneID(id).
SetLastUsedAt(now).
SetLastUsedIP(ip).
Save(ctx)
return err
}
func toAPIKey(k *ent.APIKey) *apikey.APIKey {
return &apikey.APIKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
OwnerID: k.OwnerID,
OrganizationID: k.OrganizationID,
Scopes: k.Scopes,
Description: k.Description,
Environment: apikey.Environment(k.Environment),
ExpiresAt: k.ExpiresAt,
LastUsedAt: k.LastUsedAt,
LastUsedIP: k.LastUsedIP,
Revoked: k.Revoked,
RevokedAt: k.RevokedAt,
RevokedReason: k.RevokedReason,
Metadata: k.Metadata,
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
}
}
Step 3: Create the Store and Service¶
// main.go or wire.go
import (
"github.com/plexusone/systemforge/identity/apikey"
"myapp/internal/auth"
)
// Create the Ent store
store, err := apikey.NewEntStore(apikey.EntStoreConfig{
Client: auth.NewAPIKeyClientWrapper(entClient),
})
if err != nil {
log.Fatal(err)
}
// Create the service
service := apikey.NewService(apikey.ServiceConfig{
Store: store,
Prefix: "myapp",
AllowedScopes: []string{
"read:users",
"write:users",
"read:projects",
"write:projects",
},
MaxKeysPerUser: 10,
})
Mixin Fields¶
The mixin.APIKey provides these fields:
| Field | Type | Description |
|---|---|---|
id |
UUID | Primary key |
name |
string | User-provided name |
prefix |
string | Visible prefix (e.g., sf_live_xxxx) |
key_hash |
string | SHA-256 hash (sensitive) |
owner_id |
UUID | Owner user |
organization_id |
UUID | Organization scope (optional) |
scopes |
[]string | Granted permissions |
description |
string | User-provided note (optional) |
environment |
enum | live or test |
expires_at |
time | Expiration (optional) |
last_used_at |
time | Last usage (optional) |
last_used_ip |
string | Last IP (optional) |
revoked |
bool | Revocation status |
revoked_at |
time | Revocation time (optional) |
revoked_reason |
string | Revocation reason (optional) |
metadata |
JSON | Additional data (optional) |
created_at |
time | Creation timestamp |
updated_at |
time | Update timestamp |
Indexes¶
The mixin creates indexes for common queries:
index.Fields("owner_id") // List by owner
index.Fields("organization_id") // List by organization
index.Fields("prefix") // Lookup by prefix
index.Fields("key_hash").Unique() // Validate by hash
index.Fields("environment") // Filter by environment
Service Methods¶
The apikey.Service provides these methods:
// Create a new API key (returns full key once)
result, err := service.Create(ctx, CreateKeyRequest{
Name: "My Key",
OwnerID: userID,
OrganizationID: &orgID,
Scopes: []string{"read:*"},
Description: "For CI/CD",
Environment: apikey.EnvLive,
ExpiresIn: ptr(90 * 24 * time.Hour),
Metadata: map[string]string{"team": "platform"},
})
// Validate a key
apiKey, err := service.Validate(ctx, rawKey)
// Validate with scope check
apiKey, err := service.ValidateWithScope(ctx, rawKey, "write:projects")
// Get key by ID
apiKey, err := service.Get(ctx, keyID)
// List keys for a user
keys, err := service.List(ctx, ownerID)
// List keys for an organization
keys, err := service.ListByOrganization(ctx, orgID)
// Revoke a key
err := service.Revoke(ctx, keyID, "Compromised")
// Delete a key
err := service.Delete(ctx, keyID)
// Record usage (call after validation)
err := service.RecordUsage(ctx, keyID, clientIP)
Middleware Integration¶
import (
"github.com/plexusone/systemforge/session/middleware"
)
// Use the built-in middleware
apiAuth := middleware.APIKeyAuth(middleware.APIKeyConfig{
Service: service,
RecordUsage: true,
ContextKey: "api_key",
})
// Apply to routes
r.Route("/api/v1", func(r chi.Router) {
r.Use(apiAuth)
r.Get("/projects", listProjects)
})
// Access in handler
func listProjects(w http.ResponseWriter, r *http.Request) {
principal := middleware.GetPrincipal(r.Context())
// principal.ID = owner_id
// principal.Scopes = granted scopes
}