Securing gRPC Services with JWT Authentication in Go
gRPC provides several mechanisms to enhance the security of your services. In this blog post, we’ll explore how to implement authentication in gRPC, focusing on interceptors, metadata, and JWTs (JSON Web Tokens) as the authentication mechanism.
gRPC is a popular choice for building web APIs due to its efficiency and language-agnostic architecture (read more about that here). However, as you’d expect, securing gRPC services is just as important as any other distributed system.
What are JWTs?
JSON Web Tokens (JWTs) are a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed using a secret key or a public/private key pair.
A JWT is structured into three base64url-encoded parts: a header that specifies the type of token and algorithm used, a payload that contains the claims or data, and a signature that verifies the token’s integrity.
According to the JWT specification (RFC 7519), there are a number of registered claims to include in the JWT payload. Three common claims are, “sub” that identifies the subject of the token (typically the user ID), “iat” that indicates the time the token was issued, and “exp” which specifies the expiration time after which the token is no longer valid.
JWTs are often used for authorization. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources permitted with that token. JWTs ensure that the actions performed are on behalf of the authenticated user, often storing user IDs and expiration times to prevent tampering and misuse. This also ensures the authenticated user can only access their own resources.
Interceptors in gRPC
Interceptors in gRPC are equivalent to middleware in other web frameworks. They allow you to implement generic behavior across multiple or all RPCs, on both the client and server. This could include logging, metrics collection, caching, or in this case, authentication.
To implement an interceptor, you add it to the server or client options when initializing your gRPC service. The interceptor can then handle tasks by extracting metadata from either the request or response.
You start by creating a function matching the interceptor’s signature. This function will be executed whenever an RPC call is made, allowing you to inject any necessary logic. For instance, you might want to log the request and response.
func UnaryServerInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
log.Printf("Received request on method: %s", info.FullMethod)
resp, err := handler(ctx, req)
log.Printf("Sending response from method: %s", info.FullMethod)
return resp, err
}
func main() {
server := grpc.NewServer(
grpc.UnaryInterceptor(UnaryServerInterceptor),
)
// Register your service and start the server here
}
Interceptors can also be chained, ensuring multiple layers of logic are executed in sequence. This is particularly useful when combining several concerns, such as logging, authentication, and then metrics.
func main() {
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
// first interceptor call (outer-most).
},
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
// second interceptor call (inner-most).
},
),
)
// register your service and start the server here...
}
Using headers in metadata
Now we know how to add generic functionality across RPCs, how do we send request specific data like a JWT token?
gRPC metadata allows you to attach additional information to a request or response, similar to HTTP headers. This metadata can be used for authentication, tracing, and other purposes. gRPC supports two types of metadata: headers and trailers. These are sent at different stages of the RPC lifecycle. Headers are sent before the message data, whereas Trailers are sent after the response message data is sent to the client.
To add metadata in a client request, you initialize a metadata object and include it in the context used for the RPC call.
md := metadata.Pairs("authorization", "Bearer your_jwt_token")
ctx := metadata.NewOutgoingContext(context.Background(), md)
On the server side, you can extract metadata from the incoming context to validate tokens or retrieve other necessary information.
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
token := md["authorization"]
Creating JWT Authentication on Server Side
Now we have all the building blocks to use JWT authentication for a gRPC service, Let’s walkthrough an example step-by-step!
This example will show how to generate and validate JWTs, incorporate them into your gRPC service using interceptors, and extract claims from the token to be used in our RPC handlers.
Step 1: Implement JWT Handling
Let’s start by creating a package for JWT handling. This will include methods for issuing and validating tokens.
This struct will be initialized with a secret key. This will be used to sign tokens when issuing. This secret key will then also be used to validate a provided token signature.
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type service struct {
secret []byte
}
var ErrInvalidToken = errors.New("invalid token")
func NewService(secret string) (*service, error) {
if secret == "" {
return nil, errors.New("cannot have an empty secret")
}
return &service{secret: []byte(secret)}, nil
}
// IssueToken will issue a JWT token with the provided userID as the subject. The token will expire after 15 minutes.
func (s *service) IssueToken(_ context.Context, userID string) (string, error) {
// build JWT with necessary claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"iss": time.Now().Unix(),
"exp": time.Now().Add(time.Minute * 15).Unix(), // expire after 15 minutes.
}, nil)
// sign token using the server's secret key.
signed, err := token.SignedString(s.secret)
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
return signed, nil
}
// ValidateToken will validate the provide JWT against the secret key. It'll then check if the token has expired, and then return the user ID set as the token subject.
func (s *service) ValidateToken(_ context.Context, token string) (string, error) {
// validate token for the correct secret key and signing method.
t, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.secret, nil
})
if err != nil {
return "", errors.Join(ErrInvalidToken, err)
}
// read claims from payload and extract the user ID.
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
id, ok := claims["sub"].(string)
if !ok {
return "", fmt.Errorf("%w: failed to extract id from claims", ErrInvalidToken)
}
return id, nil
}
return "", ErrInvalidToken
}
Step 2: Create the Server Interceptor
Let’s now create an interceptor which will call our auth service and validate JWTs on incoming requests.
package interceptor
import (
"context"
"errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type (
// Validator defines an interface for token validation. This is satisfied by our auth service.
Validator interface {
ValidateToken(ctx context.Context, token string) (string, error)
}
authInterceptor struct {
validator Validator
}
)
func NewAuthInterceptor(validator Validator) (*authInterceptor, error) {
if validator == nil {
return nil, errors.New("validator cannot be nil")
}
return &authInterceptor{validator: validator}, nil
}
func (i *authInterceptor) UnaryAuthMiddleware(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
// get metadata object
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "metadata is not provided")
}
// extract token from authorization header
token := md["authorization"]
if len(token) == 0 {
return nil, status.Error(codes.Unauthenticated, "authorization token is not provided")
}
// validate token and retrieve the userID
userID, err := i.authService.ValidateToken(token[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token: %v", err)
}
// add our user ID to the context, so we can use it in our RPC handler
ctx = context.WithValue(ctx, "user_id", userID)
// call our handler
return handler(ctx, req)
}
The user ID can then be used in our RPC handler by extracting it from the context.
func (h *Handler) YourRPC(ctx context.Context, request *pb.YourRPCRequest) (*pb.YourRPCResponse, error) {
// extract user ID from context
userID, ok := ctx.Value("user_id").(string)
if !ok {
return nil, status.Error(codes.FailedPrecondition, "user id missing") // return FAILED_PRECONDITION status here as the system should never get into this state
}
// rest of your RPC implementation here...
}
Step 3: Setup Your gRPC Server and Integrate the Interceptor
Finally set up a basic gRPC server by creating a main.go. Initialize the gRPC server with the UnaryInterceptor server option to enable our middleware.
package main
import (
"log"
"net"
"os"
"google.golang.org/grpc"
pb "path/to/your/protobuf/package"
)
func main() {
// listen on a port
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// get JWT secret key from environment variable
jwtSecret, ok := os.LookupEnv("JWT_SECRET")
if !ok {
log.Fatal("JWT_SECRET must be provided")
}
// initialise our auth service & interceptor
authSvc, err := auth.NewService(jwtSecret)
if err != nil {
log.Fatalf("failed to initialize auth service: %v", err)
}
interceptor, err := interceptor.NewAuthInterceptor(authSvc)
if err != nil {
log.Fatalf("failed to initialize interceptor: %v", err)
}
// create a gRPC server and register our RPC handler
s := grpc.NewServer(
grpc.UnaryInterceptor(interceptor.UnaryAuthMiddleware),
)
pb.RegisterYourServiceServer(s, &Handler{})
// start gRPC server
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
⚠️ Note: Always remember to handle your JWT secret keys securely. If an attacker gains access to this secret key, they can easily generate their own tokens, and effectively gain access to your entire system. ⚠️
Sending a JWT Token on Client Side
On the client-side there are two options, you can either set the authorization header manually on each request, or use PerRPCCredentials to inject the token automatically.
Option 1: Explicitly Setting the Token Header in Metadata
For this option we will append the token to the metadata as a header before making each RPC call.
The client will be initialised as normal:
package main
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "path/to/your/protobuf/package"
)
func main() {
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewYourServiceClient(conn)
// rest of your client implementation here...
}
When making an RPC call we’ll get the token and add it as a header.
token := getToken(ctx) // get your token from the context, or wherever it's stored
// add header to metadata
md := metadata.Pairs("authorization", token)
ctx = metadata.NewOutgoingContext(ctx, md)
// call RPC
response, err := client.YourRPC(ctx, &pb.YourRPCRequest{})
if err != nil {
log.Fatalf("could not call protected method: %v", err)
}
log.Printf("Response from protected method: %s", response)
Option 2: Using CallCredentials to Build Metadata on Each Request
Alternatively, you can use gRPC’s WithPerRPCCredentials dial option, which sets credentials and places auth state on each outbound RPC.
To do this you need to implement the PerRPCCredentials interface and pass an instance of this into the dial option.
package main
import (
"context"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "path/to/your/protobuf/package"
)
// jwtCredentials satisfies the PerRPCCredentials interface.
type jwtCredentials struct{}
// GetRequestMetadata will be called on every RPC call and returns a map which is used to build the request metadata.
func (j *jwtCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
token := getToken(ctx) // get your token from the context, or wherever it's stored
// return metadata map for RPC call
return map[string]string{
"authorization": token,
}, nil
}
func (j *jwtCredentials) RequireTransportSecurity() bool {
return false
}
func main() {
creds := &jwtCredentials{}
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithPerRPCCredentials(creds), // use dial option to set credentials on each request
)
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewYourServiceClient(conn)
response, err := client.YourRPC(ctx, &pb.YourRPCRequest{})
if err != nil {
log.Fatalf("could not call protected method: %v", err)
}
log.Printf("Response from protected method: %s", response)
}
Wrapping Up
By implementing JWT authentication, you secure your services against unauthorized access, ensuring that only legitimate requests are processed. As your architecture grows, JWT authentication provides a scalable and secure way to manage user sessions and permissions across your system.
By effectively utilizing interceptors, metadata and call credentials, you can secure your gRPC services in a way that’s both reliable and idiomatic.
How can I learn more about gRPC?
If you’re interested in diving deeper into gRPC, check out our comprehensive course here to learn everything you need to know!