Skip to main content
By Kunal Dawar
This tutorial demonstrates how to add authorization to a Go API using go-jwt-middleware v3. Learn how to validate JWTs, protect endpoints, and enforce permission-based access control.

View Sample on GitHub

Complete working example with tests • Go 1.24+ required
New to Auth0? Learn how Auth0 works and read about implementing API authentication and authorization using the OAuth 2.0 framework.

What You’ll Build

Public Endpoints

Routes accessible without authentication

Protected Endpoints

JWT validation for authenticated users

Permission-Based Access

Scope validation for fine-grained control

Production-Ready

Graceful shutdown, timeouts, and error handling

Prerequisites

Before starting, ensure you have:

Go 1.24 or later

go-jwt-middleware v3 requires Go 1.24+ and uses generics for type-safe claims. Check your version:
go version
Install or update at go.dev/doc/install
Sign up for free if you don’t have an account.
Familiarity with Go syntax, HTTP servers, and package management.

Step 1: Configure Auth0 API

1

Create an API

In the APIs section of the Auth0 dashboard, click Create API.
Create API
2

Configure API settings

  • Name: Quickstarts (or any descriptive name)
  • Identifier: https://quickstarts/api (this becomes your audience)
  • Signing Algorithm: Leave as RS256 (recommended)
The API Identifier is a logical identifier - it doesn’t need to be a real URL. Use a format like https://quickstarts/api for clarity.
3

Understand RS256

By default, your API uses RS256 (asymmetric algorithm) for signing tokens:
  • Auth0 signs tokens with a private key
  • Your API verifies tokens with a public key (from JWKS)
  • Public keys are automatically fetched from: https://{yourDomain}/.well-known/jwks.json
Learn more about JSON Web Key Sets (JWKS)

Step 2: Define API Permissions

Permissions (scopes) let you define how resources can be accessed. For example, grant read access to managers and write access to administrators.
1

Navigate to Permissions

In your API settings, click the Permissions tab.
2

Add permissions

Create the following permission for this tutorial:
PermissionDescription
read:messagesRead messages from the API
Configure Permissions
This tutorial uses the read:messages scope to protect the scoped endpoint.
This example demonstrates:
  • ✅ Extracting JWTs from the Authorization: Bearer <token> header
  • ✅ Validating tokens using Auth0’s JWKS
  • ✅ Checking token expiration and claims
  • ✅ Enforcing permission-based access with scopes
  • ✅ Type-safe claims extraction using Go generics
Learn more: Validate Access Tokens

Step 3: Install Dependencies

1

Initialize Go module

Create your project directory and initialize a Go module:
mkdir myapi && cd myapi
go mod init github.com/yourorg/myapi
2

Install Auth0 middleware

Install the v3 go-jwt-middleware package:
go get github.com/auth0/go-jwt-middleware/v3
go get github.com/joho/godotenv
Version Important: Ensure you install v3 (not v2). V3 uses generics for type-safe claims handling and requires Go 1.24+.
3

Download dependencies

go mod download
Your go.mod should look like this:
module github.com/yourorg/myapi

go 1.24

require (
    github.com/auth0/go-jwt-middleware/v3 v3.0.0
    github.com/joho/godotenv v1.5.1
)

Step 4: Configure Environment Variables

Create a .env file in your project root to store Auth0 configuration:
Security: Never commit .env files to version control. Add .env to your .gitignore file.

Step 5: Create Configuration Package

Create internal/config/auth.go to load and validate environment variables:
package config

import (
    "fmt"
    "os"
)

type AuthConfig struct {
    Domain   string
    Audience string
}

func LoadAuthConfig() (*AuthConfig, error) {
    domain := os.Getenv("AUTH0_DOMAIN")
    if domain == "" {
        return nil, fmt.Errorf("AUTH0_DOMAIN environment variable required")
    }

    audience := os.Getenv("AUTH0_AUDIENCE")
    if audience == "" {
        return nil, fmt.Errorf("AUTH0_AUDIENCE environment variable required")
    }

    return &AuthConfig{
        Domain:   domain,
        Audience: audience,
    }, nil
}
Benefits of centralized configuration:
  • Single source of truth for environment variables
  • Clear error messages for missing configuration
  • Type-safe configuration access
  • Easy to test and mock in unit tests
  • Fail fast on startup if config is invalid
Configuration management:
  • Validate all required config on startup (fail fast)
  • Use different .env files for different environments
  • Consider using config validation libraries for complex setups
  • Document all environment variables in a .env.example file

Step 6: Create Custom Claims

Custom claims allow you to extract and validate application-specific data from JWTs. Create internal/auth/claims.go:
package auth

import (
    "context"
    "fmt"
    "strings"
)

// CustomClaims contains custom data we want to parse from the JWT.
type CustomClaims struct {
    Scope string `json:"scope"`
}

// Validate ensures the custom claims are properly formatted.
func (c *CustomClaims) Validate(ctx context.Context) error {
    // Scope is optional, but if present, must be properly formatted
    if c.Scope == "" {
        return nil // No scope is valid - not all endpoints require permissions
    }

    // Validate scope format (no leading/trailing spaces, no double spaces)
    if strings.TrimSpace(c.Scope) != c.Scope {
        return fmt.Errorf("scope claim has invalid whitespace")
    }

    if strings.Contains(c.Scope, "  ") {
        return fmt.Errorf("scope claim contains double spaces")
    }

    return nil
}

// HasScope checks whether our claims have a specific scope.
func (c *CustomClaims) HasScope(expectedScope string) bool {
    if c.Scope == "" {
        return false
    }

    scopes := strings.Split(c.Scope, " ")
    for _, scope := range scopes {
        if scope == expectedScope {
            return true
        }
    }
    return false
}
Automatic Validation: The Validate method is called automatically by the middleware after parsing the JWT. You don’t need to call it manually - it’s part of the validation chain.Scope is Optional: The validation allows empty scopes because not all endpoints require permissions. This works for:
  • /api/private - Requires only authentication (no scope needed)
  • /api/private-scoped - Requires authentication + read:messages scope
Format Validation: When scope is present, validation ensures:
  • No leading/trailing whitespace
  • No double spaces (ensures clean parsing)
  • Properly formatted space-separated values
HasScope Method: Returns false if scope is empty, preventing false positives.Validation Flow:
  1. JWT is extracted from the Authorization header
  2. Claims are unmarshaled into CustomClaims struct
  3. Validate(ctx) is automatically called
  4. If validation fails, JWT is rejected before reaching your handlers

Step 7: Create JWT Validator

The validator is the core component that verifies tokens. Create internal/auth/validator.go:
package auth

import (
    "fmt"
    "net/url"
    "time"

    "github.com/auth0/go-jwt-middleware/v3/jwks"
    "github.com/auth0/go-jwt-middleware/v3/validator"
)

func NewValidator(domain, audience string) (*validator.Validator, error) {
    // Construct issuer URL (must include trailing slash)
    issuerURL, err := url.Parse("https://" + domain + "/")
    if err != nil {
        return nil, fmt.Errorf("failed to parse issuer URL: %w", err)
    }

    // Initialize JWKS provider using v3 options pattern
    provider, err := jwks.NewCachingProvider(
        jwks.WithIssuerURL(issuerURL),
        jwks.WithCacheTTL(5*time.Minute),
    )
    if err != nil {
        return nil, fmt.Errorf("failed to create JWKS provider: %w", err)
    }

    // Create validator using v3 options pattern
    jwtValidator, err := validator.New(
        validator.WithKeyFunc(provider.KeyFunc),         // Provides public keys for RS256
        validator.WithAlgorithm(validator.RS256),        // Algorithm (prevents confusion attacks)
        validator.WithIssuer(issuerURL.String()),        // Validates 'iss' claim
        validator.WithAudience(audience),                // Validates 'aud' claim
        validator.WithCustomClaims(func() validator.CustomClaims {
            return &CustomClaims{}                       // Returns our custom claims from claims.go
        }),
        validator.WithAllowedClockSkew(30*time.Second),  // Allows 30s clock drift
    )
    if err != nil {
        return nil, fmt.Errorf("failed to create validator: %w", err)
    }

    return jwtValidator, nil
}

What does the validator check?

The validator performs these security checks on every JWT:
  1. Signature verification - Using Auth0’s public keys from JWKS
  2. Issuer validation - iss claim matches your Auth0 domain
  3. Audience validation - aud claim matches your API identifier
  4. Expiration check - Token hasn’t expired (exp claim)
  5. Time validity - Token is currently valid (nbf and iat claims)
JWKS Caching: Automatically fetches and caches Auth0’s public keys every 5 minutes, reducing network calls.Algorithm Specification: Explicitly sets RS256 to prevent algorithm confusion attacks.Clock Skew Tolerance: Allows 30 seconds for distributed system clock differences.Options Pattern: V3 uses functional options (WithIssuerURL(), WithKeyFunc(), WithAlgorithm(), etc.) for flexible, type-safe configuration.Custom Claims: The CustomClaims struct lets you extract custom data from JWTs, like permission scopes.

Step 8: Create HTTP Middleware

Create internal/auth/middleware.go to wrap your validator:
package auth

import (
    "log/slog"
    "net/http"

    jwtmiddleware "github.com/auth0/go-jwt-middleware/v3"
    "github.com/auth0/go-jwt-middleware/v3/validator"
)

func NewMiddleware(jwtValidator *validator.Validator) (*jwtmiddleware.JWTMiddleware, error) {
    return jwtmiddleware.New(
        jwtmiddleware.WithValidator(jwtValidator),
        jwtmiddleware.WithValidateOnOptions(false),
        jwtmiddleware.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
            slog.Error("JWT validation failed", "error", err, "path", r.URL.Path)
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnauthorized)
            w.Write([]byte(`{"message":"Failed to validate JWT."}`))
        }),
    )
}
The middleware:
  • Extracts JWT from Authorization: Bearer <token> header
  • Validates token using the core validator
  • Skips validation for OPTIONS requests (CORS preflight)
  • Injects validated claims into request context
  • Returns custom error responses on validation failure with structured logging

Step 9: Create API Handlers

Create internal/handlers/api.go with three handlers demonstrating different protection levels:
package handlers

import (
    "encoding/json"
    "net/http"

    "github.com/yourorg/myapi/internal/auth"
    jwtmiddleware "github.com/auth0/go-jwt-middleware/v3"
    "github.com/auth0/go-jwt-middleware/v3/validator"
)

// PublicHandler - no authentication required
func PublicHandler(w http.ResponseWriter, r *http.Request) {
    response := map[string]string{
        "message": "Hello from a public endpoint! You don't need to be authenticated to see this.",
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

// PrivateHandler - requires valid JWT
func PrivateHandler(w http.ResponseWriter, r *http.Request) {
    response := map[string]string{
        "message": "Hello from a private endpoint! You need to be authenticated to see this.",
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

// ScopedHandler - requires 'read:messages' permission
func ScopedHandler(w http.ResponseWriter, r *http.Request) {
    // Extract validated claims using generics (v3 feature)
    claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context())
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnauthorized)
        w.Write([]byte(`{"message":"Unauthorized."}`))
        return
    }

    // Check for required scope in custom claims
    customClaims, ok := claims.CustomClaims.(*auth.CustomClaims)
    if !ok || !customClaims.HasScope("read:messages") {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusForbidden)
        w.Write([]byte(`{"message":"Insufficient scope."}`))
        return
    }

    response := map[string]string{
        "message": "Hello from a private endpoint! You need to be authenticated and have a scope of read:messages to see this.",
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

Step 10: Create Main Server

Create cmd/server/main.go to wire everything together:
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "time"

    "github.com/yourorg/myapi/internal/auth"
    "github.com/yourorg/myapi/internal/config"
    "github.com/yourorg/myapi/internal/handlers"
    "github.com/joho/godotenv"
)

func main() {
    // Load environment variables from .env file
    if err := godotenv.Load(); err != nil {
        log.Println("No .env file found, using environment variables")
    }

    // Load Auth0 configuration
    cfg, err := config.LoadAuthConfig()
    if err != nil {
        log.Fatalf("Failed to load config: %v", err)
    }

    // Create JWT validator
    jwtValidator, err := auth.NewValidator(cfg.Domain, cfg.Audience)
    if err != nil {
        log.Fatalf("Failed to create validator: %v", err)
    }

    // Create HTTP middleware
    middleware, err := auth.NewMiddleware(jwtValidator)
    if err != nil {
        log.Fatalf("Failed to create middleware: %v", err)
    }

    // Setup routes
    mux := http.NewServeMux()
    mux.HandleFunc("/api/public", handlers.PublicHandler)
    mux.Handle("/api/private", middleware.CheckJWT(http.HandlerFunc(handlers.PrivateHandler)))
    mux.Handle("/api/private-scoped", middleware.CheckJWT(http.HandlerFunc(handlers.ScopedHandler)))

    // Configure server with production timeouts
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Start server in goroutine
    go func() {
        log.Println("Server starting on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server failed: %v", err)
        }
    }()

    // Graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit

    log.Println("Shutting down server...")
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server exited")
}
myapi/
├── cmd/
│   └── server/
│       └── main.go              # Application entry point
├── internal/
│   ├── auth/
│   │   ├── claims.go            # Custom JWT claims
│   │   ├── middleware.go        # JWT middleware
│   │   └── validator.go         # JWT validator
│   ├── config/
│   │   └── auth.go              # Configuration loader
│   └── handlers/
│       └── api.go               # HTTP handlers (public, private, scoped)
├── .env                         # Environment variables (not committed)
├── .env.example                 # Template for environment variables
├── .gitignore
├── go.mod
├── go.sum
└── README.md

Step 11: Test Your API

1

Start the server

go run cmd/server/main.go
You should see: Server starting on :8080
2

Get a test token

Navigate to your API in the Auth0 Dashboard, click the Test tab, and copy the access token.
3

Test public endpoint

curl http://localhost:8080/api/public
Expected: 200 OK with public message
4

Test private endpoint (no token)

curl http://localhost:8080/api/private
Expected: 401 Unauthorized
5

Test private endpoint (with token)

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     http://localhost:8080/api/private
Expected: 200 OK with user information
6

Test scoped endpoint

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     http://localhost:8080/api/private-scoped
Expected: 200 OK if token has read:messages scope, 403 Forbidden otherwise

Continue Learning

Quick Troubleshooting

Error: {"error": "invalid_token", "description": "aud claim mismatch"}Solution: Verify AUTH0_AUDIENCE matches your API Identifier exactly from the Auth0 Dashboard.
Error: error fetching keys: connection refusedSolution:
  • Check network connectivity to Auth0
  • Test JWKS endpoint: curl https://your-tenant.us.auth0.com/.well-known/jwks.json
  • Verify firewall/proxy settings
Error: {"error": "invalid_token", "description": "token is expired"}Solution: Get a new token from the Auth0 Dashboard Test tab.

Next Steps


Edit on GitHub