gRPC Authentication Methods

TLS secures the transport layer. Authentication answers a different question: who is the caller, and do they have permission to make this call? gRPC does not mandate one authentication scheme — it provides hooks so you plug in the right method for your system.

Authentication vs Authorisation

Authentication: Proves WHO you are.
  "I am Alice. Here is my token to prove it."

Authorisation:  Proves WHAT you can do.
  "Alice can read orders but cannot delete users."

gRPC handles:   Authentication (via metadata + interceptors)
Your code handles: Authorisation (checking roles/permissions)

The Three Common gRPC Authentication Patterns

┌───────────────────────┬──────────────────────────────────────────────────┐
│ Pattern               │ Best For                                         │
├───────────────────────┼──────────────────────────────────────────────────┤
│ Token (JWT / Bearer)  │ User-facing services, OAuth2, web/mobile clients │
│ API Key               │ Server-to-server, third-party integrations       │
│ Mutual TLS (mTLS)     │ Internal service mesh, zero-trust networks       │
└───────────────────────┴──────────────────────────────────────────────────┘

Pattern 1: JWT Bearer Token Authentication

A JSON Web Token (JWT) is a signed string that carries identity claims. The client obtains a JWT from an auth service, then sends it in the authorization metadata header on every call.

JWT structure (three base64url parts separated by dots):
  eyJhbGciOiJIUzI1NiJ9        ← Header  (algorithm: HS256)
  .eyJ1c2VySWQiOiI0MiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwMDAwMH0=
                               ← Payload (userId, role, expiry)
  .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
                               ← Signature (server verifies this)

Decoded payload:
  {
    "userId": "42",
    "role":   "admin",
    "exp":    1700000   ← Unix timestamp of expiry
  }
Client — attach JWT to every call (Go):
  import "google.golang.org/grpc/metadata"

  func callWithJWT(client pb.OrderServiceClient, token string) {
    md := metadata.Pairs("authorization", "Bearer " + token)
    ctx := metadata.NewOutgoingContext(context.Background(), md)

    resp, err := client.GetOrder(ctx, &pb.GetOrderRequest{OrderId: 1})
    // ...
  }

  // For reuse across many calls, implement PerRPCCredentials:
  type jwtCredential struct{ token string }

  func (j jwtCredential) GetRequestMetadata(ctx context.Context, uri ...string) (
      map[string]string, error) {
    return map[string]string{
      "authorization": "Bearer " + j.token,
    }, nil
  }

  func (j jwtCredential) RequireTransportSecurity() bool { return true }

  // Attach to channel — every call on this channel carries the token
  conn, _ := grpc.NewClient("server:443",
    grpc.WithTransportCredentials(creds),
    grpc.WithPerRPCCredentials(jwtCredential{token: myToken}),
  )
Server — validate JWT in an interceptor (Go):
  import (
    "github.com/golang-jwt/jwt/v5"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
  )

  var secretKey = []byte("your-secret-key")

  func authInterceptor(ctx context.Context, req interface{},
      info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {

    // 1. Read metadata
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
      return nil, status.Error(codes.Unauthenticated, "missing metadata")
    }

    // 2. Get the authorization header
    values := md.Get("authorization")
    if len(values) == 0 {
      return nil, status.Error(codes.Unauthenticated, "missing token")
    }

    // 3. Strip "Bearer " prefix
    tokenStr := strings.TrimPrefix(values[0], "Bearer ")

    // 4. Parse and validate the JWT
    token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
      if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
      }
      return secretKey, nil
    })

    if err != nil || !token.Valid {
      return nil, status.Error(codes.Unauthenticated, "invalid token")
    }

    // 5. Extract claims and attach to context for downstream use
    claims := token.Claims.(jwt.MapClaims)
    ctx = context.WithValue(ctx, "userId", claims["userId"])

    return handler(ctx, req)   // call the actual handler
  }

  // Register the interceptor on the server
  grpcServer := grpc.NewServer(
    grpc.ChainUnaryInterceptor(authInterceptor),
  )

Pattern 2: API Key Authentication

An API key is a static secret string. The client sends it in metadata. The server looks it up in a database or cache to identify and authorise the caller. API keys are simpler than JWTs but carry no expiry by themselves.

Client — send API key:
  md := metadata.Pairs("x-api-key", "sk_live_AbCdEf123456")
  ctx := metadata.NewOutgoingContext(context.Background(), md)
  resp, _ := client.GetData(ctx, req)

Server — validate API key:
  func apiKeyInterceptor(ctx context.Context, req interface{},
      info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {

    md, _ := metadata.FromIncomingContext(ctx)
    keys := md.Get("x-api-key")
    if len(keys) == 0 {
      return nil, status.Error(codes.Unauthenticated, "API key required")
    }

    // Look up the key in your database or in-memory cache
    caller, err := keyStore.Lookup(keys[0])
    if err != nil {
      return nil, status.Error(codes.Unauthenticated, "invalid API key")
    }

    ctx = context.WithValue(ctx, "caller", caller)
    return handler(ctx, req)
  }

Pattern 3: mTLS Authentication

With mTLS the client presents a certificate that was signed by the trusted CA. The server extracts the identity from the certificate's Common Name (CN) or Subject Alternative Name (SAN) field. No token or header is needed — the TLS handshake handles authentication.

Server — extract client identity from TLS certificate:
  import (
    "crypto/tls"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/peer"
  )

  func (s *server) GetData(ctx context.Context,
      req *pb.DataRequest) (*pb.DataResponse, error) {

    // Extract TLS info from the call context
    p, ok := peer.FromContext(ctx)
    if !ok {
      return nil, status.Error(codes.Unauthenticated, "no peer information")
    }

    tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
    if !ok {
      return nil, status.Error(codes.Unauthenticated, "not a TLS connection")
    }

    // The client's identity is in the certificate
    if len(tlsInfo.State.PeerCertificates) == 0 {
      return nil, status.Error(codes.Unauthenticated, "no client certificate")
    }

    clientCN := tlsInfo.State.PeerCertificates[0].Subject.CommonName
    // clientCN = "payment-service" or "order-service" etc.

    log.Printf("Authenticated caller: %s", clientCN)
    return &pb.DataResponse{}, nil
  }

Token Refresh Pattern

JWTs expire. A production client must refresh the token before it expires and update ongoing calls automatically.

Token refresh flow:
┌────────────────────────────────────────────────────────────┐
│                                                            │
│  Start ──► Login ──► Receive JWT (expires in 1 hour)       │
│                           │                                │
│                           ▼                                │
│            gRPC calls with JWT in metadata                 │
│                           │                                │
│            (55 minutes later — 5 min before expiry)        │
│                           ▼                                │
│        Background goroutine refreshes token using          │
│        the refresh token from the auth service             │
│                           │                                │
│                           ▼                                │
│            New JWT stored; subsequent calls use new token  │
└────────────────────────────────────────────────────────────┘

Implementation hint in Go:
  type tokenManager struct {
    mu      sync.RWMutex
    token   string
    expiry  time.Time
  }

  func (tm *tokenManager) GetRequestMetadata(ctx context.Context,
      uri ...string) (map[string]string, error) {

    tm.mu.RLock()
    token := tm.token
    expiry := tm.expiry
    tm.mu.RUnlock()

    if time.Until(expiry) < 5*time.Minute {
      // Refresh in background or block and refresh
      token = tm.refresh()
    }

    return map[string]string{"authorization": "Bearer " + token}, nil
  }

Combining Authentication and Authorisation

Request flow with auth:
┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│  Client request                                                     │
│       │                                                             │
│       ▼                                                             │
│  TLS Handshake (transport security)                                 │
│       │                                                             │
│       ▼                                                             │
│  Auth Interceptor (authentication — who are you?)                   │
│  • Reads token / API key / mTLS cert                                │
│  • Validates it                                                     │
│  • Attaches caller identity to context                              │
│       │                                                             │
│       ▼                                                             │
│  Authz Interceptor (authorisation — what can you do?)               │
│  • Reads caller identity from context                               │
│  • Checks role against required permission for this method          │
│  • Returns PERMISSION_DENIED if not allowed                         │
│       │                                                             │
│       ▼                                                             │
│  Service Handler (actual business logic)                            │
└─────────────────────────────────────────────────────────────────────┘

Summary

gRPC authentication always travels in metadata. JWT bearer tokens suit user-facing flows and OAuth2 integrations. API keys work well for server-to-server integrations where simplicity matters. mTLS authenticates services at the transport layer without any token, making it ideal for internal zero-trust networks. Place authentication logic in an interceptor so it runs on every call without repeating code in every service method.

Leave a Comment

Your email address will not be published. Required fields are marked *