Keycloak + gRPC: Authentication with Envoy Proxy

Guilliano Molaire Guilliano Molaire Updated June 9, 2026 10 min read

Last updated: March 2026

Introduction

gRPC services present a different authentication challenge than REST APIs. The binary protocol, HTTP/2 transport, and metadata-based headers mean standard middleware patterns do not always apply directly. Envoy Proxy, widely used as a sidecar in service mesh architectures, provides a clean solution through its external authorization filter (ext_authz) and built-in JWT authentication filter.

This guide covers how to authenticate gRPC services with Keycloak using Envoy as the authentication layer. We set up JWT validation at the Envoy level, configure per-method authorization based on Keycloak roles, and implement a Go gRPC interceptor as a fallback validation layer. The result is a defense-in-depth approach where Envoy handles token verification and your application handles business-level authorization.

For REST API gateway patterns with Keycloak, see our Kong integration guide and Traefik integration guide.

Architecture

The authentication flow for gRPC through Envoy:

  1. A gRPC client obtains an access token from Keycloak.
  2. The client includes the token as metadata (authorization: Bearer <token>).
  3. Envoy intercepts the request and validates the JWT using Keycloak’s JWKS.
  4. If valid, Envoy forwards the request to the gRPC service with decoded claims as metadata.
  5. The gRPC service uses the forwarded claims for business-level authorization.

This keeps authentication concerns out of your gRPC service code.

Prerequisites

  • Docker and Docker Compose
  • Go 1.22+ (for the gRPC service example)
  • grpcurl for testing (brew install grpcurl or go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest)
  • A running Keycloak instance (use our Docker Compose Generator to set one up)

Protobuf Definitions

Start with a simple service definition:

// proto/project.proto
syntax = "proto3";

package project.v1;

option go_package = "github.com/example/grpc-auth/gen/project/v1";

service ProjectService {
  // Public - no auth required
  rpc GetHealth (HealthRequest) returns (HealthResponse);

  // Requires authentication
  rpc ListProjects (ListProjectsRequest) returns (ListProjectsResponse);
  rpc GetProject (GetProjectRequest) returns (Project);

  // Requires admin role
  rpc DeleteProject (DeleteProjectRequest) returns (DeleteProjectResponse);
}

message HealthRequest {}
message HealthResponse {
  string status = 1;
}

message ListProjectsRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListProjectsResponse {
  repeated Project projects = 1;
  string next_page_token = 2;
}

message GetProjectRequest {
  string project_id = 1;
}

message DeleteProjectRequest {
  string project_id = 1;
}

message DeleteProjectResponse {
  bool success = 1;
}

message Project {
  string id = 1;
  string name = 2;
  string description = 3;
  string owner_id = 4;
  string created_at = 5;
}

Generate the Go code:

protoc --go_out=. --go-grpc_out=. proto/project.proto

Go gRPC Service

Implement the service with an authorization interceptor:

// main.go
package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "strings"

    pb "github.com/example/grpc-auth/gen/project/v1"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
)

// ProjectServer implements the ProjectService.
type ProjectServer struct {
    pb.UnimplementedProjectServiceServer
}

func (s *ProjectServer) GetHealth(
    ctx context.Context, req *pb.HealthRequest,
) (*pb.HealthResponse, error) {
    return &pb.HealthResponse{Status: "healthy"}, nil
}

func (s *ProjectServer) ListProjects(
    ctx context.Context, req *pb.ListProjectsRequest,
) (*pb.ListProjectsResponse, error) {
    userID := getUserIDFromContext(ctx)
    log.Printf("ListProjects called by user: %s", userID)

    // In production, filter by user ownership
    projects := []*pb.Project{
        {
            Id: "proj-1", Name: "Auth Service",
            Description: "SSO integration",
            OwnerId: userID, CreatedAt: "2026-01-15",
        },
        {
            Id: "proj-2", Name: "API Gateway",
            Description: "Envoy + Keycloak",
            OwnerId: userID, CreatedAt: "2026-03-01",
        },
    }

    return &pb.ListProjectsResponse{Projects: projects}, nil
}

func (s *ProjectServer) GetProject(
    ctx context.Context, req *pb.GetProjectRequest,
) (*pb.Project, error) {
    userID := getUserIDFromContext(ctx)
    log.Printf("GetProject %s called by user: %s",
        req.ProjectId, userID)

    return &pb.Project{
        Id: req.ProjectId, Name: "Auth Service",
        Description: "SSO integration",
        OwnerId: userID, CreatedAt: "2026-01-15",
    }, nil
}

func (s *ProjectServer) DeleteProject(
    ctx context.Context, req *pb.DeleteProjectRequest,
) (*pb.DeleteProjectResponse, error) {
    userID := getUserIDFromContext(ctx)
    roles := getRolesFromContext(ctx)
    log.Printf("DeleteProject %s called by user: %s (roles: %v)",
        req.ProjectId, userID, roles)

    return &pb.DeleteProjectResponse{Success: true}, nil
}

// getUserIDFromContext extracts the user ID from gRPC metadata.
// Envoy forwards this from the JWT 'sub' claim.
func getUserIDFromContext(ctx context.Context) string {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return "unknown"
    }
    // Envoy sets this header from JWT claims
    if vals := md.Get("x-auth-sub"); len(vals) > 0 {
        return vals[0]
    }
    return "unknown"
}

// getRolesFromContext extracts roles forwarded by Envoy.
func getRolesFromContext(ctx context.Context) []string {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil
    }
    if vals := md.Get("x-auth-roles"); len(vals) > 0 {
        return strings.Split(vals[0], ",")
    }
    return nil
}

// authorizationInterceptor provides defense-in-depth authorization.
// Envoy handles JWT validation; this handles method-level authorization.
func authorizationInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {

    // Public methods - no auth required
    publicMethods := map[string]bool{
        "/project.v1.ProjectService/GetHealth": true,
    }
    if publicMethods[info.FullMethod] {
        return handler(ctx, req)
    }

    // All other methods require authentication
    userID := getUserIDFromContext(ctx)
    if userID == "unknown" {
        return nil, status.Error(codes.Unauthenticated,
            "authentication required")
    }

    // Admin-only methods
    adminMethods := map[string]bool{
        "/project.v1.ProjectService/DeleteProject": true,
    }
    if adminMethods[info.FullMethod] {
        roles := getRolesFromContext(ctx)
        hasAdmin := false
        for _, role := range roles {
            if role == "admin" {
                hasAdmin = true
                break
            }
        }
        if !hasAdmin {
            return nil, status.Error(codes.PermissionDenied,
                "admin role required")
        }
    }

    return handler(ctx, req)
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    server := grpc.NewServer(
        grpc.UnaryInterceptor(authorizationInterceptor),
    )
    pb.RegisterProjectServiceServer(server, &ProjectServer{})

    log.Println("gRPC server listening on :50051")
    if err := server.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Envoy Configuration

JWT Authentication Filter

Envoy’s built-in jwt_authn filter validates JWTs against Keycloak’s JWKS endpoint:

# envoy.yaml
static_resources:
  listeners:
    - name: grpc_listener
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8443
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: grpc_ingress
                codec_type: AUTO
                http2_protocol_options: {}

                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: grpc_service
                      domains: ["*"]
                      routes:
                        # Health check - no auth
                        - match:
                            prefix: "/project.v1.ProjectService/GetHealth"
                          route:
                            cluster: grpc_backend
                        # All other methods - require auth
                        - match:
                            prefix: "/"
                          route:
                            cluster: grpc_backend

                http_filters:
                  # JWT Authentication
                  - name: envoy.filters.http.jwt_authn
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
                      providers:
                        keycloak:
                          issuer: "http://keycloak:8080/realms/grpc-demo"
                          audiences:
                            - "grpc-service"
                          remote_jwks:
                            http_uri:
                              uri: "http://keycloak:8080/realms/grpc-demo/protocol/openid-connect/certs"
                              cluster: keycloak_cluster
                              timeout: 5s
                            cache_duration:
                              seconds: 600
                          forward: true
                          # Extract claims to headers
                          claim_to_headers:
                            - header_name: x-auth-sub
                              claim_name: sub
                            - header_name: x-auth-email
                              claim_name: email
                            - header_name: x-auth-preferred-username
                              claim_name: preferred_username

                      rules:
                        # Health endpoint - no JWT required
                        - match:
                            prefix: "/project.v1.ProjectService/GetHealth"
                        # All other endpoints - require JWT
                        - match:
                            prefix: "/"
                          requires:
                            provider_name: keycloak

                  # Lua filter to extract nested role claims
                  - name: envoy.filters.http.lua
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
                      default_source_code:
                        inline_string: |
                          function envoy_on_request(request_handle)
                            -- The JWT payload is available after jwt_authn
                            local jwt_payload = request_handle:headers():get("x-jwt-payload")
                            if jwt_payload then
                              -- Decode base64 payload
                              local decoded = request_handle:base64Escape(jwt_payload)
                              -- In production, parse JSON and extract
                              -- realm_access.roles
                            end
                          end

                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
    - name: grpc_backend
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      typed_extension_protocol_options:
        envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
          "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
          explicit_http_config:
            http2_protocol_options: {}
      load_assignment:
        cluster_name: grpc_backend
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: grpc-service
                      port_value: 50051

    - name: keycloak_cluster
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:
        cluster_name: keycloak_cluster
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: keycloak
                      port_value: 8080

External Authorization Filter (Alternative)

For more complex authorization decisions, use the ext_authz filter to delegate to a dedicated authorization service:

# Add this to http_filters before the router
- name: envoy.filters.http.ext_authz
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
    grpc_service:
      envoy_grpc:
        cluster_name: authz_service
      timeout: 2s
    failure_mode_allow: false
    with_request_body:
      max_request_bytes: 8192
      allow_partial_message: true
    transport_api_version: V3

The external authorization service can introspect the Keycloak token, check RBAC policies, and return allow/deny decisions with custom headers.

Docker Compose

# docker-compose.yml
version: "3.9"

services:
  keycloak-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak_pass
    volumes:
      - kc_data:/var/lib/postgresql/data

  keycloak:
    image: quay.io/keycloak/keycloak:26.1.0
    command: start-dev
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak_pass
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"
    depends_on:
      - keycloak-db

  envoy:
    image: envoyproxy/envoy:v1.32-latest
    volumes:
      - ./envoy.yaml:/etc/envoy/envoy.yaml:ro
    ports:
      - "8443:8443"    # gRPC ingress
      - "9901:9901"    # Envoy admin
    depends_on:
      - grpc-service
      - keycloak

  grpc-service:
    build: .
    ports:
      - "50051:50051"

volumes:
  kc_data:

Keycloak Configuration

Realm and Client

  1. Create realm grpc-demo.
  2. Create client:
Setting Value
Client ID grpc-client
Client Authentication ON
Service Accounts Enabled ON
  1. Create realm roles: admin, user.
  2. Create a test user and assign the user role.
  3. Optionally create a second client grpc-service as the audience for the access tokens.

Configure Audience Mapper

To ensure the access token includes the correct audience:

  1. Go to the grpc-client > Client Scopes > Dedicated scope.
  2. Add a mapper of type Audience.
  3. Set the Included Client Audience to grpc-service.

Inspect your tokens with the JWT Token Analyzer to verify the aud claim includes grpc-service.

Testing

Get a Token

TOKEN=$(curl -s -X POST 
  "http://localhost:8080/realms/grpc-demo/protocol/openid-connect/token" 
  -d "grant_type=password" 
  -d "client_id=grpc-client" 
  -d "client_secret=YOUR_SECRET" 
  -d "username=testuser" 
  -d "password=testpassword" 
  -d "scope=openid" | jq -r '.access_token')

Test gRPC Through Envoy

# Health check (no auth needed)
grpcurl -plaintext localhost:8443 
  project.v1.ProjectService/GetHealth

# List projects (requires auth)
grpcurl -plaintext 
  -H "authorization: Bearer ${TOKEN}" 
  localhost:8443 
  project.v1.ProjectService/ListProjects

# Without token (should fail)
grpcurl -plaintext localhost:8443 
  project.v1.ProjectService/ListProjects
# Error: Jwt is missing

# Delete project (requires admin role)
grpcurl -plaintext 
  -H "authorization: Bearer ${TOKEN}" 
  -d '{"project_id": "proj-1"}' 
  localhost:8443 
  project.v1.ProjectService/DeleteProject
# Error if user lacks admin role

gRPC Client with Keycloak Token

Here is a Go gRPC client that automatically obtains and refreshes Keycloak tokens:

// client/main.go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "strings"
    "time"

    pb "github.com/example/grpc-auth/gen/project/v1"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/metadata"
)

type tokenResponse struct {
    AccessToken  string `json:"access_token"`
    ExpiresIn    int    `json:"expires_in"`
    RefreshToken string `json:"refresh_token"`
}

func getToken(
    keycloakURL, realm, clientID, clientSecret string,
) (*tokenResponse, error) {
    tokenURL := fmt.Sprintf(
        "%s/realms/%s/protocol/openid-connect/token",
        keycloakURL, realm,
    )

    data := url.Values{}
    data.Set("grant_type", "client_credentials")
    data.Set("client_id", clientID)
    data.Set("client_secret", clientSecret)
    data.Set("scope", "openid")

    resp, err := http.Post(
        tokenURL, "application/x-www-form-urlencoded",
        strings.NewReader(data.Encode()),
    )
    if err != nil {
        return nil, fmt.Errorf("token request failed: %w", err)
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    var token tokenResponse
    if err := json.Unmarshal(body, &token); err != nil {
        return nil, fmt.Errorf("token parse failed: %w", err)
    }
    return &token, nil
}

func main() {
    // Get token from Keycloak
    token, err := getToken(
        "http://localhost:8080", "grpc-demo",
        "grpc-client", "YOUR_SECRET",
    )
    if err != nil {
        log.Fatalf("Failed to get token: %v", err)
    }

    // Connect to Envoy
    conn, err := grpc.NewClient(
        "localhost:8443",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        log.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()

    client := pb.NewProjectServiceClient(conn)

    // Create context with auth metadata
    ctx, cancel := context.WithTimeout(
        context.Background(), 10*time.Second,
    )
    defer cancel()

    md := metadata.Pairs(
        "authorization", "Bearer "+token.AccessToken,
    )
    ctx = metadata.NewOutgoingContext(ctx, md)

    // Call ListProjects
    resp, err := client.ListProjects(
        ctx, &pb.ListProjectsRequest{PageSize: 10},
    )
    if err != nil {
        log.Fatalf("ListProjects failed: %v", err)
    }

    for _, p := range resp.Projects {
        fmt.Printf("Project: %s (%s)n", p.Name, p.Id)
    }
}

Service Mesh Integration

In a service mesh like Istio, Envoy sidecars are automatically injected. You configure JWT validation through Istio’s RequestAuthentication and AuthorizationPolicy resources:

# istio-auth.yaml
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: keycloak-jwt
  namespace: default
spec:
  selector:
    matchLabels:
      app: grpc-service
  jwtRules:
    - issuer: "https://keycloak.example.com/realms/grpc-demo"
      jwksUri: "https://keycloak.example.com/realms/grpc-demo/protocol/openid-connect/certs"
      audiences:
        - "grpc-service"
      forwardOriginalToken: true
      outputClaimToHeaders:
        - header: x-auth-sub
          claim: sub
        - header: x-auth-email
          claim: email

---

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: grpc-service-authz
  namespace: default
spec:
  selector:
    matchLabels:
      app: grpc-service
  rules:
    # Allow health check without auth
    - to:
        - operation:
            methods: ["POST"]
            paths: ["/project.v1.ProjectService/GetHealth"]
    # Require valid JWT for everything else
    - from:
        - source:
            requestPrincipals: ["*"]
      to:
        - operation:
            methods: ["POST"]

Production Considerations

Token Caching in Clients

gRPC clients should cache access tokens and refresh them before expiry:

type tokenManager struct {
    token     string
    expiresAt time.Time
    mu        sync.Mutex
}

func (tm *tokenManager) getToken() (string, error) {
    tm.mu.Lock()
    defer tm.mu.Unlock()

    // Refresh 30 seconds before expiry
    if time.Now().Before(tm.expiresAt.Add(-30 * time.Second)) {
        return tm.token, nil
    }

    // Fetch new token
    resp, err := getToken(...)
    if err != nil {
        return "", err
    }
    tm.token = resp.AccessToken
    tm.expiresAt = time.Now().Add(
        time.Duration(resp.ExpiresIn) * time.Second,
    )
    return tm.token, nil
}

Observability

Enable Envoy’s gRPC access logging to track authentication events. Forward logs to your SIEM alongside Keycloak audit events for a complete picture.

Key Rotation

Envoy’s remote_jwks configuration automatically fetches updated keys from Keycloak’s JWKS endpoint. The cache_duration setting controls how often Envoy refreshes the key cache. Keep this reasonable (5-10 minutes) to balance performance against key rotation latency.

For monitoring active sessions and token usage, Skycloak’s insights dashboard provides real-time visibility.

Conclusion

Envoy Proxy provides a clean separation between authentication (JWT validation) and application logic (business authorization) for gRPC services. The jwt_authn filter handles token verification using Keycloak’s JWKS endpoint, while claim-to-header mapping passes identity context to your services without requiring JWT libraries in every microservice.

Key takeaways:

  • Envoy’s jwt_authn filter validates Keycloak JWTs at the proxy layer
  • claim_to_headers forwards identity information as gRPC metadata
  • Per-method authorization can be handled in Envoy or in application interceptors
  • Istio RequestAuthentication provides the same pattern for service meshes
  • Defense-in-depth: validate at the proxy and authorize in the application

For the full Envoy JWT documentation, see Envoy JWT Authentication. For Keycloak client configuration, consult the Keycloak Securing Applications Guide.

Need a managed Keycloak instance for your microservices architecture? See Skycloak pricing and deploy a production-ready identity provider with enterprise security in minutes.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman