Tutorial

How to Add License Key Validation to Your Go Application

TOT
Traffic Orchestrator Team
Product Engineering
May 22, 2026 12 min read 3,427 words
Share

Go is one of the most popular languages for shipping commercial software — CLI tools, microservices, desktop utilities, embedded agents, and infrastructure software. Its static binary compilation, zero-dependency distribution, and cross-compilation make it the ideal choice for licensed software that needs to run anywhere. This guide walks through implementing license key validation in Go from first principles, building up to a production-grade licensing package with offline support, concurrency safety, and middleware integration.

Why Go Is Ideal for Licensed Software

Before diving into code, it is worth understanding why Go and software licensing fit together so well:

  • Static binariesgo build produces a single executable with no runtime dependencies. You ship one file. No installer, no dependency resolution, no "please install .NET 8 first." This makes license enforcement straightforward because there is exactly one artifact to protect.
  • Cross-compilationGOOS=linux GOARCH=arm64 go build gives you an ARM64 Linux binary from your macOS laptop. Licensed software often targets multiple platforms, and Go eliminates the per-platform build complexity that plagues C/C++ projects.
  • Embedded systems and IoT — Go binaries run on Raspberry Pi, industrial gateways, and edge devices. These environments frequently operate offline, making Ed25519-based offline validation essential.
  • CLI tools — The Go ecosystem powers tools like Docker, Kubernetes, Terraform, and Vault. If you are building commercial CLI software, Go is the de facto standard.
  • Microservices — Go dominates the cloud-native space. License validation as HTTP middleware or gRPC interceptor slots naturally into existing service architectures.

Basic License Validation with HTTP Client

The simplest integration is a direct HTTP call to the Traffic Orchestrator validation API. Go's standard library provides everything you need — no third-party HTTP client required.

The Validation Request

Define the request and response types, then make the API call:

package license

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

const apiBaseURL = "https://api.trafficorchestrator.com/api/v3"

// ValidateRequest is the payload sent to the validation endpoint.
type ValidateRequest struct {
	LicenseKey string            `json:"licenseKey"`
	Domain     string            `json:"domain,omitempty"`
	MachineID  string            `json:"machineId,omitempty"`
	Metadata   map[string]string `json:"metadata,omitempty"`
}

// ValidateResponse is the API response from validation.
type ValidateResponse struct {
	Valid     bool      `json:"valid"`
	License  *License  `json:"license,omitempty"`
	Error    string    `json:"error,omitempty"`
}

// License holds the details of a validated license.
type License struct {
	ID         string    `json:"id"`
	Key        string    `json:"key"`
	Status     string    `json:"status"`
	Tier       string    `json:"tier"`
	Features   []string  `json:"features"`
	ExpiresAt  time.Time `json:"expiresAt"`
	MaxDomains int       `json:"maxDomains"`
}

// Validate checks a license key against the Traffic Orchestrator API.
func Validate(ctx context.Context, apiKey string, req ValidateRequest) (*ValidateResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		apiBaseURL+"/licenses/validate",
		bytes.NewReader(body),
	)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+apiKey)

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("http request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var result ValidateResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}

	return &result, nil
}

Error Handling with Go Idioms

Go does not use exceptions. Every function returns an error alongside its result, forcing the caller to handle failures explicitly. This is ideal for license validation where you need to distinguish between "license invalid" (a business logic result) and "network failure" (an infrastructure problem):

result, err := license.Validate(ctx, apiKey, license.ValidateRequest{
	LicenseKey: key,
	Domain:     "app.example.com",
})

if err != nil {
	// Infrastructure failure: network timeout, DNS error, API down.
	// Decide whether to fail open or fail closed.
	log.Printf("validation error: %v", err)
	return handleValidationError(err)
}

if !result.Valid {
	// Business logic: key is expired, revoked, or does not exist.
	return fmt.Errorf("license invalid: %s", result.Error)
}

// License is valid — proceed with the licensed operation.
log.Printf("license %s valid, tier: %s", result.License.ID, result.License.Tier)

This two-level error handling — separating transport errors from validation results — is a pattern you will see throughout this guide. It maps naturally to Go's error philosophy: errors are values, not control flow.

Production-Grade Middleware

Most Go applications are web services. Rather than calling Validate() in every handler, wrap license checking into middleware that runs before your business logic.

Standard Library (net/http)

package middleware

import (
	"context"
	"net/http"
	"strings"

	"yourapp/license"
)

type contextKey string

const licenseContextKey contextKey = "license"

// RequireLicense returns middleware that validates the license key
// from the X-License-Key header on every request.
func RequireLicense(apiKey string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			key := r.Header.Get("X-License-Key")
			if key == "" {
				key = strings.TrimPrefix(
					r.Header.Get("Authorization"), "LicenseKey ",
				)
			}

			if key == "" {
				http.Error(w, "{"error":"missing license key"}", http.StatusUnauthorized)
				return
			}

			result, err := license.Validate(r.Context(), apiKey, license.ValidateRequest{
				LicenseKey: key,
				Domain:     r.Host,
			})

			if err != nil {
				http.Error(w, "{"error":"validation service unavailable"}", http.StatusServiceUnavailable)
				return
			}

			if !result.Valid {
				http.Error(w, "{"error":"invalid license"}", http.StatusForbidden)
				return
			}

			// Attach the license to the request context for downstream handlers.
			ctx := context.WithValue(r.Context(), licenseContextKey, result.License)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// GetLicense extracts the validated license from the request context.
func GetLicense(ctx context.Context) *license.License {
	l, _ := ctx.Value(licenseContextKey).(*license.License)
	return l
}

Gin Middleware

If you use Gin, the middleware pattern adapts cleanly:

package middleware

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"yourapp/license"
)

// GinRequireLicense validates the license key on every request.
func GinRequireLicense(apiKey string) gin.HandlerFunc {
	return func(c *gin.Context) {
		key := c.GetHeader("X-License-Key")
		if key == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "missing license key",
			})
			return
		}

		result, err := license.Validate(c.Request.Context(), apiKey, license.ValidateRequest{
			LicenseKey: key,
			Domain:     c.Request.Host,
		})

		if err != nil {
			c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
				"error": "validation service unavailable",
			})
			return
		}

		if !result.Valid {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
				"error": "invalid license",
			})
			return
		}

		c.Set("license", result.License)
		c.Next()
	}
}

The pattern for Echo and Fiber is nearly identical — replace the context accessors with each framework's API, but the validation logic stays the same.

gRPC Unary Interceptor

For gRPC services, use a unary server interceptor to validate license keys passed via metadata:

package middleware

import (
	"context"
	"fmt"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	"yourapp/license"
)

// UnaryLicenseInterceptor validates the license key from gRPC metadata.
func UnaryLicenseInterceptor(apiKey string) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req any,
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (any, error) {
		md, ok := metadata.FromIncomingContext(ctx)
		if !ok {
			return nil, status.Error(codes.Unauthenticated, "missing metadata")
		}

		keys := md.Get("x-license-key")
		if len(keys) == 0 {
			return nil, status.Error(codes.Unauthenticated, "missing license key")
		}

		result, err := license.Validate(ctx, apiKey, license.ValidateRequest{
			LicenseKey: keys[0],
		})

		if err != nil {
			return nil, status.Error(codes.Unavailable, fmt.Sprintf("validation error: %v", err))
		}

		if !result.Valid {
			return nil, status.Error(codes.PermissionDenied, "invalid license")
		}

		ctx = context.WithValue(ctx, licenseContextKey, result.License)
		return handler(ctx, req)
	}
}

CLI Flag / Environment Variable

For CLI tools, accept the license key via flag or environment variable:

package main

import (
	"context"
	"flag"
	"fmt"
	"os"
	"time"

	"yourapp/license"
)

func main() {
	licenseKey := flag.String("license-key", "", "License key for activation")
	flag.Parse()

	// Fall back to environment variable
	if *licenseKey == "" {
		*licenseKey = os.Getenv("APP_LICENSE_KEY")
	}

	if *licenseKey == "" {
		fmt.Fprintln(os.Stderr, "error: license key required (--license-key or APP_LICENSE_KEY)")
		os.Exit(1)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	result, err := license.Validate(ctx, os.Getenv("TO_API_KEY"), license.ValidateRequest{
		LicenseKey: *licenseKey,
	})

	if err != nil {
		fmt.Fprintf(os.Stderr, "error: could not validate license: %v
", err)
		os.Exit(1)
	}

	if !result.Valid {
		fmt.Fprintf(os.Stderr, "error: invalid license: %s
", result.Error)
		os.Exit(1)
	}

	fmt.Printf("License valid (tier: %s). Starting application...
", result.License.Tier)
	// ... proceed with your app logic
}

Offline Validation with Ed25519

Go's standard library includes crypto/ed25519, making offline signature verification trivial with no CGO dependency. This is critical for CLI tools, embedded systems, and any deployment that operates behind firewalls or in air-gapped environments.

Verifying Signed License Tokens

When a license is validated online, Traffic Orchestrator can return a signed token containing the license payload and an Ed25519 signature. Your application stores this token locally and verifies it without any network access:

package license

import (
	"crypto/ed25519"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"time"
)

// Your Traffic Orchestrator account's Ed25519 public key (from dashboard).
// This is safe to embed in the binary — it can only verify, not sign.
const publicKeyBase64 = "MCowBQYDK2VwAyEA..."

// OfflineToken represents a signed license payload for offline verification.
type OfflineToken struct {
	Payload   string `json:"payload"`
	Signature string `json:"signature"`
}

// OfflinePayload is the decoded content of a signed token.
type OfflinePayload struct {
	LicenseID string    `json:"licenseId"`
	Key       string    `json:"key"`
	Tier      string    `json:"tier"`
	Features  []string  `json:"features"`
	MachineID string    `json:"machineId"`
	IssuedAt  time.Time `json:"issuedAt"`
	ExpiresAt time.Time `json:"expiresAt"`
}

// VerifyOffline checks the Ed25519 signature and expiry of an offline token.
func VerifyOffline(token OfflineToken) (*OfflinePayload, error) {
	pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
	if err != nil {
		return nil, fmt.Errorf("decode public key: %w", err)
	}
	pubKey := ed25519.PublicKey(pubKeyBytes)

	sigBytes, err := base64.StdEncoding.DecodeString(token.Signature)
	if err != nil {
		return nil, fmt.Errorf("decode signature: %w", err)
	}

	payloadBytes := []byte(token.Payload)

	if !ed25519.Verify(pubKey, payloadBytes, sigBytes) {
		return nil, fmt.Errorf("invalid signature")
	}

	var payload OfflinePayload
	if err := json.Unmarshal(payloadBytes, &payload); err != nil {
		return nil, fmt.Errorf("decode payload: %w", err)
	}

	if time.Now().After(payload.ExpiresAt) {
		return nil, fmt.Errorf("token expired at %s", payload.ExpiresAt.Format(time.RFC3339))
	}

	return &payload, nil
}

Caching Validated Tokens with Expiry

Store the token on the filesystem after a successful online validation and fall back to it when the API is unreachable:

package license

import (
	"encoding/json"
	"os"
	"path/filepath"
)

const tokenFilename = ".license-token.json"

// CacheToken writes the offline token to a file in the user's config directory.
func CacheToken(token OfflineToken) error {
	dir, err := os.UserConfigDir()
	if err != nil {
		return err
	}

	appDir := filepath.Join(dir, "yourapp")
	if err := os.MkdirAll(appDir, 0700); err != nil {
		return err
	}

	data, err := json.Marshal(token)
	if err != nil {
		return err
	}

	return os.WriteFile(filepath.Join(appDir, tokenFilename), data, 0600)
}

// LoadCachedToken reads the offline token from the cache directory.
func LoadCachedToken() (*OfflineToken, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return nil, err
	}

	data, err := os.ReadFile(filepath.Join(dir, "yourapp", tokenFilename))
	if err != nil {
		return nil, err
	}

	var token OfflineToken
	if err := json.Unmarshal(data, &token); err != nil {
		return nil, err
	}

	return &token, nil
}

// ValidateWithFallback tries online validation first, then offline.
func ValidateWithFallback(ctx context.Context, apiKey string, req ValidateRequest) (*ValidateResponse, error) {
	result, err := Validate(ctx, apiKey, req)
	if err == nil {
		return result, nil
	}

	// Online validation failed — try cached offline token.
	token, loadErr := LoadCachedToken()
	if loadErr != nil {
		return nil, fmt.Errorf("online: %w, offline: %w", err, loadErr)
	}

	payload, verifyErr := VerifyOffline(*token)
	if verifyErr != nil {
		return nil, fmt.Errorf("online: %w, offline: %w", err, verifyErr)
	}

	return &ValidateResponse{
		Valid: true,
		License: &License{
			ID:       payload.LicenseID,
			Key:      payload.Key,
			Tier:     payload.Tier,
			Features: payload.Features,
		},
	}, nil
}

Advanced Patterns

Graceful Degradation with Feature Flags

Instead of a binary valid/invalid check, use the license tier and feature list to control what functionality is available:

package license

// FeatureGate checks whether the validated license includes a specific feature.
func FeatureGate(l *License, feature string) bool {
	if l == nil {
		return false
	}
	for _, f := range l.Features {
		if f == feature {
			return true
		}
	}
	return false
}

// Usage in a handler:
//
//   lic := middleware.GetLicense(r.Context())
//   if !license.FeatureGate(lic, "advanced-analytics") {
//       http.Error(w, "upgrade required", http.StatusPaymentRequired)
//       return
//   }

Rate-Limited Validation

Calling the validation API on every request adds latency and consumes your validation quota. Cache the result in memory with a TTL:

package license

import (
	"context"
	"sync"
	"time"
)

// CachedValidator wraps the Validate function with an in-memory TTL cache.
type CachedValidator struct {
	apiKey   string
	ttl      time.Duration
	mu       sync.RWMutex
	cache    map[string]*cachedResult
}

type cachedResult struct {
	response  *ValidateResponse
	expiresAt time.Time
}

// NewCachedValidator creates a validator that caches results for the given TTL.
func NewCachedValidator(apiKey string, ttl time.Duration) *CachedValidator {
	return &CachedValidator{
		apiKey: apiKey,
		ttl:    ttl,
		cache:  make(map[string]*cachedResult),
	}
}

// Validate checks the cache first, then calls the API if the cache is stale.
func (cv *CachedValidator) Validate(ctx context.Context, req ValidateRequest) (*ValidateResponse, error) {
	cv.mu.RLock()
	if cached, ok := cv.cache[req.LicenseKey]; ok && time.Now().Before(cached.expiresAt) {
		cv.mu.RUnlock()
		return cached.response, nil
	}
	cv.mu.RUnlock()

	result, err := Validate(ctx, cv.apiKey, req)
	if err != nil {
		return nil, err
	}

	cv.mu.Lock()
	cv.cache[req.LicenseKey] = &cachedResult{
		response:  result,
		expiresAt: time.Now().Add(cv.ttl),
	}
	cv.mu.Unlock()

	return result, nil
}

A 5-minute TTL means each unique license key triggers at most 12 API calls per hour instead of thousands. Adjust the TTL based on your security requirements — shorter for high-value licenses, longer for high-traffic services.

Context-Aware Validation

Go's context.Context carries deadlines, cancellation signals, and request-scoped values. Always pass context through your validation chain so callers can control timeouts and cancellation:

// Use a short timeout for validation in hot paths.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

result, err := validator.Validate(ctx, license.ValidateRequest{
	LicenseKey: key,
})

if err != nil {
	// context.DeadlineExceeded means the validation API took too long.
	// Decide whether to fail open (allow) or fail closed (deny).
	if errors.Is(err, context.DeadlineExceeded) {
		// Fail open: use cached result if available
		return serveCachedResponse(w, r, key)
	}
	http.Error(w, "service unavailable", http.StatusServiceUnavailable)
	return
}

Building a Go Licensing Package

For anything beyond a prototype, organize your licensing code as a reusable internal package. Here is a recommended structure:

pkg/license/
  ├── client.go          // HTTP client, Validate(), API types
  ├── offline.go         // Ed25519 verification, token caching
  ├── cache.go           // Thread-safe in-memory cache
  ├── middleware.go       // HTTP middleware (net/http, Gin, Echo)
  ├── interceptor.go     // gRPC unary + stream interceptors
  ├── options.go         // Functional options for Client configuration
  └── license_test.go    // Table-driven tests

Configuration via Functional Options

Use the functional options pattern for a clean, extensible client API:

package license

import (
	"net/http"
	"time"
)

// Client is a thread-safe license validation client.
type Client struct {
	apiKey     string
	baseURL    string
	httpClient *http.Client
	cacheTTL   time.Duration
	cache      *CachedValidator
}

// Option configures a Client.
type Option func(*Client)

// WithBaseURL overrides the default API base URL.
func WithBaseURL(url string) Option {
	return func(c *Client) { c.baseURL = url }
}

// WithHTTPClient sets a custom HTTP client (useful for proxies, custom TLS).
func WithHTTPClient(hc *http.Client) Option {
	return func(c *Client) { c.httpClient = hc }
}

// WithCacheTTL sets the in-memory cache duration for validation results.
func WithCacheTTL(ttl time.Duration) Option {
	return func(c *Client) { c.cacheTTL = ttl }
}

// NewClient creates a configured license validation client.
func NewClient(apiKey string, opts ...Option) *Client {
	c := &Client{
		apiKey:     apiKey,
		baseURL:    apiBaseURL,
		httpClient: &http.Client{Timeout: 10 * time.Second},
		cacheTTL:   5 * time.Minute,
	}

	for _, opt := range opts {
		opt(c)
	}

	c.cache = NewCachedValidator(c.apiKey, c.cacheTTL)
	return c
}

Callers configure the client at initialization:

client := license.NewClient(
	os.Getenv("TO_API_KEY"),
	license.WithCacheTTL(10*time.Minute),
	license.WithBaseURL("https://api.trafficorchestrator.com/api/v3"),
)

Testing License Validation

Go's testing tools make it straightforward to test license validation without hitting a real API.

httptest Server for Mocking

Use net/http/httptest to create a local server that mimics the Traffic Orchestrator API:

package license_test

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"context"

	"yourapp/pkg/license"
)

func TestValidate_ValidKey(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer test-api-key" {
			t.Error("missing or incorrect Authorization header")
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(license.ValidateResponse{
			Valid: true,
			License: &license.License{
				ID:     "lic_123",
				Tier:   "professional",
				Features: []string{"analytics", "export"},
			},
		})
	}))
	defer server.Close()

	client := license.NewClient(
		"test-api-key",
		license.WithBaseURL(server.URL),
	)

	result, err := client.Validate(context.Background(), license.ValidateRequest{
		LicenseKey: "TO-XXXX-XXXX-XXXX",
	})

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if !result.Valid {
		t.Error("expected valid license")
	}

	if result.License.Tier != "professional" {
		t.Errorf("expected tier professional, got %s", result.License.Tier)
	}
}

Table-Driven Tests

Test multiple validation scenarios with Go's table-driven test pattern:

func TestValidate_Scenarios(t *testing.T) {
	tests := []struct {
		name       string
		key        string
		serverResp license.ValidateResponse
		serverCode int
		wantValid  bool
		wantErr    bool
	}{
		{
			name:       "valid professional license",
			key:        "TO-VALID-PRO-KEY",
			serverResp: license.ValidateResponse{Valid: true, License: &license.License{Tier: "professional"}},
			serverCode: 200,
			wantValid:  true,
		},
		{
			name:       "expired license",
			key:        "TO-EXPIRED-KEY",
			serverResp: license.ValidateResponse{Valid: false, Error: "license_expired"},
			serverCode: 200,
			wantValid:  false,
		},
		{
			name:       "revoked license",
			key:        "TO-REVOKED-KEY",
			serverResp: license.ValidateResponse{Valid: false, Error: "license_revoked"},
			serverCode: 200,
			wantValid:  false,
		},
		{
			name:       "server error",
			key:        "TO-ANY-KEY",
			serverCode: 500,
			wantErr:    true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.WriteHeader(tt.serverCode)
				if tt.serverCode == 200 {
					json.NewEncoder(w).Encode(tt.serverResp)
				}
			}))
			defer server.Close()

			client := license.NewClient("test-key", license.WithBaseURL(server.URL))
			result, err := client.Validate(context.Background(), license.ValidateRequest{
				LicenseKey: tt.key,
			})

			if tt.wantErr {
				if err == nil {
					t.Error("expected error, got nil")
				}
				return
			}

			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}

			if result.Valid != tt.wantValid {
				t.Errorf("valid = %v, want %v", result.Valid, tt.wantValid)
			}
		})
	}
}

Build Tags for License-Gated Features

Use Go build tags to compile different feature sets based on license tier at build time:

// file: features_pro.go
//go:build pro

package features

// ProAnalytics enables the advanced analytics module.
// This file is only included when built with: go build -tags pro
func ProAnalytics() bool { return true }
// file: features_default.go
//go:build !pro

package features

func ProAnalytics() bool { return false }

Build the professional edition with go build -tags pro. The default build excludes pro features entirely — the code is never compiled into the binary, providing compile-time enforcement.

Distribution Considerations

Static Binaries and Embedded Checks

Go's static linking means your license validation code is compiled directly into the binary. There is no separate DLL or shared library to strip out. Embed your public key for offline verification at compile time:

import _ "embed"

//go:embed keys/ed25519-public.pem
var publicKeyPEM []byte

The key is baked into the binary at compile time. Users cannot replace it without recompiling, which they cannot do without your source code.

CGO-Free Validation

Go's crypto/ed25519 is a pure Go implementation — no CGO required. This means your offline validation works on every platform Go supports, including architectures without a C toolchain:

# Build for every major platform — all with license validation
CGO_ENABLED=0 GOOS=linux   GOARCH=amd64 go build -o dist/myapp-linux-amd64
CGO_ENABLED=0 GOOS=linux   GOARCH=arm64 go build -o dist/myapp-linux-arm64
CGO_ENABLED=0 GOOS=darwin  GOARCH=arm64 go build -o dist/myapp-darwin-arm64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/myapp-windows-amd64.exe

Every binary includes the same license validation logic, Ed25519 verification, and offline fallback. No platform-specific code, no runtime dependencies, no dynamic linking.

Docker Multi-Stage Builds

For containerized deployments, use a multi-stage build that validates the license at build time (for CI/CD pipelines) and embeds runtime validation:

# Stage 1: Build
FROM golang:1.23-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Embed the license public key and build a static binary
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server

# Stage 2: Runtime (distroless for minimal attack surface)
FROM gcr.io/distroless/static-debian12

COPY --from=builder /app/server /server

# License key injected at runtime via environment variable
ENV APP_LICENSE_KEY=""

ENTRYPOINT ["/server"]

The license key is injected at runtime via environment variable or Kubernetes secret — never baked into the image. The binary validates the key on startup using the embedded public key for offline verification or the API for online validation.

ApproachWhen to UseSecurity Level
Environment variableContainer orchestrators, CI/CDMedium — readable by anyone with container access
Kubernetes SecretProduction Kubernetes clustersHigh — encrypted at rest, RBAC-controlled
HashiCorp VaultEnterprise, regulated environmentsHighest — dynamic secrets, audit logging
Embedded at build timeAppliance binaries, air-gappedMedium — key travels with the binary

Putting It All Together

Here is the complete validation flow for a production Go application:

  1. Initialize the client — create a license.Client with functional options at startup. Set the cache TTL based on your security requirements.
  2. Attach middleware — use the HTTP middleware or gRPC interceptor to validate the license on every request. The middleware caches results in memory via CachedValidator.
  3. Feature gating — use the license tier and feature list from the validation response to control which endpoints and functionality are available.
  4. Offline fallback — when the API is unreachable, verify the cached Ed25519-signed token locally. Pure Go, no CGO, works on every platform.
  5. Graceful degradation — if both online and offline validation fail, enter a degraded mode that preserves core functionality while disabling premium features.
  6. Testing — use httptest servers and table-driven tests to cover every validation scenario without hitting the real API.

The entire pkg/license/ package compiles to roughly 50 KB of additional binary size. It has zero external dependencies beyond Go's standard library for the core validation path (only the HTTP framework middleware imports framework-specific packages). It cross-compiles to every platform Go supports, runs without CGO, and handles offline scenarios with cryptographic integrity.

License Your Go Application Today

Traffic Orchestrator provides the validation API, Ed25519 offline tokens, domain binding, and analytics — you write the Go code. The free Builder plan includes 5 licenses, 500 validations/month, and full API access with no credit card required.

Create Free Account
TOT
Traffic Orchestrator Team
Product Engineering

The engineering team behind Traffic Orchestrator, building enterprise-grade software licensing infrastructure used by developers worldwide.

Was this article helpful?
Get licensing insights delivered

Engineering deep-dives, security advisories, and product updates. Unsubscribe anytime.

Share this article
Free Plan Available

Ship licensing in your next release

5 licenses, 500 validations/month, full API access. Set up in under 5 minutes — no credit card required.

2-minute setup No credit card Cancel anytime