A decoupled, high-performance authentication and authorization orchestrator
Heimdall is a central, pluggable microservice written in Go that functions as both an Identity Orchestrator and a Policy Decision Point (PDP). It orchestrates best-in-class, cloud-native authentication and authorization services while maintaining complete decoupling from your business logic.
- Purpose
- Architecture Philosophy
- Tech Stack
- Getting Started
- Usage Guide
- Developer Documentation
- Integration Steps
- API Reference
- Contributing
- License
Heimdall solves the problem of tightly-coupled authentication and authorization in modern microservices architectures. Instead of building yet another custom auth service, Heimdall orchestrates industry-leading open-source tools into a cohesive, event-driven system.
- Vendor Lock-In: Heimdall's pluggable architecture means you're never locked into a specific auth provider
- Business Logic Coupling: Heimdall knows nothing about your business domain—it only understands principals, resources, and attributes
- N+1 Query Problem: Built-in support for query planning enables efficient list filtering without individual checks
- Synchronous External Calls: Event-driven architecture prevents blocking calls to external services during authorization
- Token Security: Uses PASETO (not JWT) for cryptographically secure tokens by default
- Orchestrates Authentication: Wraps identity providers (like Ory Kratos/Hydra) for SSO, OIDC, and OAuth2 flows
- Centralizes Authorization: Integrates with policy engines (like Cerbos) for fine-grained, attribute-based access control
- Mints Secure Tokens: Issues PASETO v4 tokens with principal identity and attributes
- Synchronizes State: Consumes events from business services (via NATS JetStream) to keep authorization attributes up-to-date
- Enables Gateway Integration: Provides simple HTTP APIs that your API Gateway can call to make auth decisions
Heimdall follows a core principle: "Orchestrate, Don't Create"
-
Pluggable by Design: All code is written against interfaces defined in
/internal/core/interfaces.go. Implementations live in/internal/plugins/. -
Decoupled from Business Logic: Heimdall operates on generic concepts (principals, resources, attributes)—not your domain models.
-
Event-Driven: External state synchronization happens asynchronously via message queues, never blocking auth decisions.
-
Secure by Default: PASETO tokens, not JWT. Attribute-based access control, not role-based.
-
Cloud-Native: Designed for Kubernetes, Docker Compose, and container orchestration from day one.
Heimdall uses the following battle-tested technologies:
| Component | Technology | Purpose |
|---|---|---|
| HTTP Framework | go-chi/chi v5 | Lightweight, idiomatic HTTP routing |
| Authentication | Ory Kratos & Hydra | Identity management and OAuth2/OIDC |
| Authorization | Cerbos | Policy decision point with attribute-based access control |
| Tokens | PASETO v4 | Platform-agnostic security tokens |
| Event Bus | NATS JetStream | Reliable, distributed messaging |
| Language | Go 1.24+ | Performance, concurrency, and simplicity |
- Docker & Docker Compose (for local development)
- Go 1.24+ (if building from source)
- Make (optional, for build automation)
The fastest way to run Heimdall with all dependencies:
# Clone the repository
git clone https://github.com/Nexlified/heimdall.git
cd heimdall
# Create .env file from example
cp .env.example .env
# Edit .env file and set secure values for:
# - POSTGRES_PASSWORD
# - KRATOS_DSN
# - HYDRA_OIDC_SALT
# - HYDRA_SECRETS_SYSTEM
# - PASETO_SYMMETRIC_KEY (must be exactly 32 bytes)
# Start all services (Heimdall, Kratos, Hydra, Cerbos, NATS)
docker-compose up -d
# Verify Heimdall is running
curl http://localhost:8080/healthThis starts:
- Heimdall on
http://localhost:8080 - Ory Kratos (admin: 4434, public: 4433)
- Ory Hydra (admin: 4445, public: 4444)
- Cerbos (gRPC: 3592, HTTP: 3593)
- NATS JetStream (client: 4222, monitoring: 8222)
Heimdall is configured via environment variables. For local development, copy the .env.example file to .env and customize the values:
cp .env.example .envThe following environment variables must be set (they have no defaults):
# Database password for Kratos
POSTGRES_PASSWORD=your-secure-database-password
# Kratos database connection string
KRATOS_DSN=postgres://kratos:your-password@kratos-db:5432/kratos?sslmode=disable&max_conns=20
# Hydra OIDC pairwise salt (used for subject identifier generation)
HYDRA_OIDC_SALT=a-very-secret-salt-change-this
# Hydra system secrets (used for encryption)
HYDRA_SECRETS_SYSTEM=you-should-change-this-to-a-long-random-string
# PASETO symmetric key (MUST be exactly 32 bytes for AES-256)
PASETO_SYMMETRIC_KEY=change-this-to-32-byte-secret!!# PostgreSQL database configuration
POSTGRES_USER=kratos # Default: kratos
POSTGRES_DB=kratos # Default: kratos
# Service connection URLs
NATS_URL=nats://nats:4222 # Default: nats://nats:4222
KRATOS_ADMIN_URL=http://kratos:4434 # Default: http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445 # Default: http://hydra:4445
CERBOS_GRPC_URL=cerbos:3592 # Default: cerbos:3592
# Hydra DSN (use 'memory' for dev, postgres for production)
HYDRA_DSN=memory # Default: memory
# Server configuration
PORT=8080 # Default: 8080Security Note: Never commit your .env file to version control. It's already included in .gitignore.
See .env.example for a complete template with all available options.
Your frontend redirects users to Heimdall's login endpoint:
# User navigates to:
GET http://localhost:8080/auth/login?redirect_uri=https://myapp.com/dashboardThis triggers the OAuth2/OIDC flow with Kratos and Hydra.
After successful authentication, Kratos/Hydra redirects to:
GET http://localhost:8080/auth/callback?code=<authorization_code>Heimdall exchanges the code for tokens and returns a PASETO token:
{
"access_token": "v4.local.encoded-paseto-token...",
"expires_in": 3600
}Include the token in subsequent requests:
curl -H "Authorization: Bearer v4.local...." \
http://localhost:8080/checkCheck if a principal can perform actions on resources:
POST http://localhost:8080/check
Content-Type: application/json
{
"principal": {
"id": "user123",
"roles": ["user", "editor"],
"attr": {
"department": "engineering",
"plan": "pro"
}
},
"resources": [
{
"kind": "document",
"id": "doc456",
"attr": {
"owner": "user123",
"status": "draft"
},
"actions": ["view", "edit", "delete"]
}
]
}Response (200 OK = allowed, 403 Forbidden = denied):
{
"resourceId": "doc456",
"actions": {
"view": "EFFECT_ALLOW",
"edit": "EFFECT_ALLOW",
"delete": "EFFECT_DENY"
}
}Solve the N+1 query problem by getting a query plan for filtering lists:
POST http://localhost:8080/plan/resources
Content-Type: application/json
{
"principal": {
"id": "user123",
"roles": ["user"],
"attr": {
"department": "engineering"
}
},
"resource": {
"kind": "document",
"attr": {}
},
"actions": ["view"]
}Response:
{
"filter": {
"kind": "KIND_CONDITIONAL",
"condition": {
"expression": "request.resource.attr.owner == request.principal.id || request.resource.attr.public == true"
}
}
}Use this filter in your database query to fetch only authorized resources.
heimdall/
├── cmd/
│ └── heimdall/
│ └── main.go # Application entry point
├── internal/
│ ├── core/
│ │ └── interfaces.go # Core interfaces (NEVER modify implementations here)
│ ├── handlers/
│ │ ├── http.go # HTTP request handlers
│ │ └── http_test.go # Handler tests
│ ├── plugins/ # Plugin implementations
│ │ ├── authn/
│ │ │ └── kratos/ # Ory Kratos/Hydra identity provider
│ │ ├── authz/
│ │ │ └── cerbos/ # Cerbos policy engine
│ │ └── events/
│ │ └── nats/ # NATS JetStream event consumer
│ └── tokens/
│ └── paseto.go # PASETO token service
├── infra/ # Infrastructure configs
│ ├── kratos/
│ │ └── kratos.yml
│ └── cerbos/
│ └── policies/
├── docker-compose.yml # Local development stack
├── Dockerfile # Multi-stage production build
└── go.mod # Go dependencies
All features are built against these interfaces (see /internal/core/interfaces.go):
type IdentityProvider interface {
InitiateLogin(w http.ResponseWriter, r *http.Request)
HandleAuthCallback(r *http.Request) (*TokenResponse, error)
RefreshToken(refreshToken string) (*TokenResponse, error)
}type PolicyEngine interface {
Check(ctx context.Context, checkRequest []byte) ([]byte, error)
PlanResources(ctx context.Context, planRequest []byte) ([]byte, error)
UpdateAttributes(ctx context.Context, principalID string, attributes map[string]any) error
}type EventConsumer interface {
Consume(pdp PolicyEngine) error
}# Install dependencies
go mod download
# Build the binary
go build -o heimdall ./cmd/heimdall
# Run locally (requires env vars)
./heimdall# Run all tests
go test ./...
# Run with verbose output
go test -v ./...
# Run specific package tests
go test ./internal/plugins/authz/cerbos/...
# Run with coverage
go test -cover ./...Heimdall's architecture makes it easy to add new implementations:
- Create a new package under
/internal/plugins/authn/<provider>/ - Implement the
core.IdentityProviderinterface - Write comprehensive tests
- Update
cmd/heimdall/main.goto wire it up
// internal/plugins/authn/auth0/auth0.go
package auth0
import "github.com/nexlified/heimdall/internal/core"
type Client struct {
// Your implementation
}
func (c *Client) InitiateLogin(w http.ResponseWriter, r *http.Request) {
// Implement Auth0 login flow
}
func (c *Client) HandleAuthCallback(r *http.Request) (*core.TokenResponse, error) {
// Implement Auth0 callback handling
}
func (c *Client) RefreshToken(refreshToken string) (*core.TokenResponse, error) {
// Implement token refresh
}Heimdall is designed to work as a sidecar or upstream service for your API Gateway (Kong, Traefik, Nginx, etc.).
Configure your gateway to call Heimdall before routing requests:
# Traefik example
http:
middlewares:
heimdall-auth:
forwardAuth:
address: "http://heimdall:8080/check"
authResponseHeaders:
- "X-User-ID"
- "X-User-Roles"# Kong example
plugins:
- name: external-auth
config:
uri: "http://heimdall:8080/check"
method: "POST"Your application can call Heimdall's API directly:
// In your Go application
func (s *Server) ListDocuments(w http.ResponseWriter, r *http.Request) {
// 1. Get user from token (set by gateway or middleware)
userID := r.Header.Get("X-User-ID")
// 2. Get query plan from Heimdall
plan, err := s.heimdallClient.PlanResources(ctx, PlanRequest{
Principal: Principal{ID: userID, Roles: []string{"user"}},
Resource: Resource{Kind: "document"},
Actions: []string{"view"},
})
// 3. Apply plan to database query
docs, err := s.db.ListDocuments(ctx, plan.Filter)
// 4. Return results
json.NewEncoder(w).Encode(docs)
}Heimdall stays up-to-date with your business state via NATS JetStream events.
Billing Service (when subscription changes):
// Publish subscription.updated event
natsConn.Publish("subscription.updated", []byte(`{
"user_id": "user123",
"plan": "enterprise",
"attributes": {
"max_users": 500,
"features": ["sso", "audit", "priority_support"]
}
}`))Business Service (when usage metrics change):
// Publish usage.updated event
natsConn.Publish("usage.updated", []byte(`{
"user_id": "user123",
"attributes": {
"current_users": 287,
"storage_used_gb": 1250,
"api_calls_today": 45000
}
}`))Heimdall's EventConsumer automatically:
- Listens for these events
- Validates and parses them
- Calls
PolicyEngine.UpdateAttributes()to acknowledge receipt - Ensures attributes are available for subsequent authorization checks
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Frontend │─────▶│ API Gateway │─────▶│ Heimdall │
│ (React) │ │ (Traefik) │ │ (AuthN/Z) │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
│ │
▼ ▼
┌──────────────┐ ┌─────────────┐
│ Business │─────▶│ NATS │
│ Services │ │ JetStream │
└──────────────┘ └─────────────┘
│
│
┌──────▼──────┐
│ Cerbos │
│ (Policies) │
└─────────────┘
// Login flow
const login = async () => {
window.location.href = 'http://heimdall.example.com/auth/login?redirect_uri='
+ encodeURIComponent(window.location.origin + '/dashboard');
};
// Handle callback
const handleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const response = await fetch('http://heimdall.example.com/auth/callback', {
method: 'GET',
headers: { 'Authorization': `Bearer ${code}` }
});
const { access_token } = await response.json();
localStorage.setItem('token', access_token);
};
// Make authorized requests
const fetchDocuments = async () => {
const token = localStorage.getItem('token');
const response = await fetch('http://api.example.com/documents', {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
};| Endpoint | Method | Description |
|---|---|---|
/auth/login |
GET | Initiates OAuth2/OIDC login flow |
/auth/callback |
GET | Handles OAuth2 callback, returns PASETO token |
/auth/refresh |
POST | Exchanges refresh token for new access token |
| Endpoint | Method | Description |
|---|---|---|
/check |
POST | Performs access control check |
/plan/resources |
POST | Returns query plan for list filtering |
See Usage Guide for detailed examples.
We welcome contributions! Please follow these guidelines:
- Read the Architecture: Review
/internal/core/interfaces.goand the Developer Documentation - Write Tests: All new code must have comprehensive unit tests
- Follow Conventions: Use
gofmt,go vet, and write idiomatic Go - Open Issues First: Discuss significant changes before implementing
- Document Your Code: Add comments for complex logic
See Implemented-By-Copilot.md for examples of completed implementations.
This project is licensed under the MIT License. See LICENSE file for details.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Documentation: Project Wiki
Built with ❤️ by the Nexlified team