Keycloak + gRPC: Authentication with Envoy Proxy
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:
- A gRPC client obtains an access token from Keycloak.
- The client includes the token as metadata (
authorization: Bearer <token>). - Envoy intercepts the request and validates the JWT using Keycloak’s JWKS.
- If valid, Envoy forwards the request to the gRPC service with decoded claims as metadata.
- 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)
grpcurlfor testing (brew install grpcurlorgo 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
- Create realm
grpc-demo. - Create client:
| Setting | Value |
|---|---|
| Client ID | grpc-client |
| Client Authentication | ON |
| Service Accounts Enabled | ON |
- Create realm roles:
admin,user. - Create a test user and assign the
userrole. - Optionally create a second client
grpc-serviceas the audience for the access tokens.
Configure Audience Mapper
To ensure the access token includes the correct audience:
- Go to the
grpc-client> Client Scopes > Dedicated scope. - Add a mapper of type Audience.
- 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_authnfilter validates Keycloak JWTs at the proxy layer claim_to_headersforwards identity information as gRPC metadata- Per-method authorization can be handled in Envoy or in application interceptors
- Istio
RequestAuthenticationprovides 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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.