gRPC Authentication With TLS Tokens and mTLS in Go
Secure gRPC in Go with TLS, per-RPC bearer tokens and mutual TLS, using an in-memory CA, and authorise by the identity in the client certificate.

gRPC carries everything in HTTP/2 frames, and by default it sends them in the clear. Authentication in gRPC has two jobs. Transport security makes the channel private and proves who the server is. Call credentials prove who the caller is, per call. The library gives you both as composable pieces, and most security mistakes come from using one and assuming it does the work of the other.
Tested with Go 1.25.6, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)
This article builds the full stack in Go: server TLS, client verification, per-RPC bearer tokens, and mutual TLS where the server checks the client's certificate and authorises by it. The lab creates its own certificate authority in memory, so you can run everything without openssl or files. It connects to interceptors, where checks run, and to API authentication for the token side. It ran on Go 1.25.6.
Three questions, three mechanisms
Who is the server? TLS with a server certificate. The client checks that the certificate was issued by a CA it trusts and that it matches the name it dialled. This stops anyone from impersonating the server and anyone on the network from reading or changing the traffic.
Which service is calling? Mutual TLS (mTLS): the client also presents a certificate, signed by a CA the server trusts. The identity is in the certificate, proved by the handshake, and cannot be forged by setting a header. This is the standard way services authenticate each other inside a system.
Which user is this call for? A token (a JWT, an opaque session token, an API key) sent as metadata with each call, usually the authorization header. This is per-RPC credentials in gRPC. Tokens carry user identity and scope, and can be short-lived.
In a typical backend, the connection between two services uses mTLS, which identifies the calling service, and a token inside the call identifies the end user on whose behalf it acts. The lab ends with exactly that combination.
An in-memory CA
The first file builds a certificate authority and issues leaf certificates, using only the standard library: an ECDSA P-256 key, an x509.Certificate template, and x509.CreateCertificate. It is a lab convenience, and it also shows what a certificate contains: a subject common name, DNS names (for servers), a validity period, key usage and an extended key usage that says whether it is for servers or clients.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"time"
)
// A throwaway certificate authority, built in memory so the lab needs no files and no openssl.
type ca struct {
cert *x509.Certificate
key *ecdsa.PrivateKey
pool *x509.CertPool
}
func newCA() *ca {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Lab Root CA"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
cert, _ := x509.ParseCertificate(der)
pool := x509.NewCertPool()
pool.AddCert(cert)
return &ca{cert, key, pool}
}
// issue signs a leaf certificate for a server (with DNS names) or a client (with a common name that names the service).
func (c *ca) issue(cn string, serial int64, server bool, dns ...string) tls.Certificate {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
usage := x509.ExtKeyUsageClientAuth
if server {
usage = x509.ExtKeyUsageServerAuth
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: cn}, DNSNames: dns,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, c.cert, &key.PublicKey, c.key)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}syntax = "proto3";
package secure.v1;
option go_package = "example.test/lab/securepb";
service Vault {
rpc WhoAmI(WhoAmIRequest) returns (WhoAmIReply);
}
message WhoAmIRequest {}
message WhoAmIReply { string peer = 1; string user = 2; }//go:build tools
package main
import (
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
pb "example.test/lab/securepb"
)
type vault struct{ pb.UnimplementedVaultServer }
func (vault) WhoAmI(ctx context.Context, _ *pb.WhoAmIRequest) (*pb.WhoAmIReply, error) {
reply := &pb.WhoAmIReply{Peer: "(no client certificate)", User: "(no token)"}
if p, ok := peer.FromContext(ctx); ok {
if info, ok := p.AuthInfo.(credentials.TLSInfo); ok && len(info.State.PeerCertificates) > 0 {
reply.Peer = info.State.PeerCertificates[0].Subject.CommonName // proven by the TLS handshake, not claimed by the caller
}
}
if md, ok := metadata.FromIncomingContext(ctx); ok {
if v := md.Get("authorization"); len(v) > 0 {
reply.User = strings.TrimPrefix(v[0], "Bearer ")
}
}
return reply, nil
}
// onlyServices is an interceptor that authorises by the identity in the client certificate.
func onlyServices(allowed ...string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) {
p, _ := peer.FromContext(ctx)
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok || len(tlsInfo.State.PeerCertificates) == 0 {
return nil, status.Error(codes.Unauthenticated, "client certificate required")
}
cn := tlsInfo.State.PeerCertificates[0].Subject.CommonName
for _, a := range allowed {
if a == cn {
return next(ctx, req)
}
}
return nil, status.Errorf(codes.PermissionDenied, "service %q may not call this method", cn)
}
}
// bearer is per-RPC credentials: attached to every call, and refusing to travel over a connection without TLS.
type bearer struct{ token string }
func (b bearer) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return map[string]string{"authorization": "Bearer " + b.token}, nil
}
func (bearer) RequireTransportSecurity() bool { return true }
func serve(creds credentials.TransportCredentials, opts ...grpc.ServerOption) (addr string, stop func()) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer(append([]grpc.ServerOption{grpc.Creds(creds)}, opts...)...)
pb.RegisterVaultServer(srv, vault{})
go srv.Serve(lis)
return lis.Addr().String(), srv.Stop
}
func try(label, addr string, opts ...grpc.DialOption) {
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
fmt.Printf("%-52s -> dial error: %v\n", label, err)
return
}
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
r, err := pb.NewVaultClient(conn).WhoAmI(ctx, &pb.WhoAmIRequest{})
if err != nil {
msg := status.Convert(err).Message()
if len(msg) > 70 {
msg = msg[:70] + "..."
}
fmt.Printf("%-52s -> %s: %s\n", label, status.Code(err), msg)
return
}
fmt.Printf("%-52s -> OK peer=%q user=%q\n", label, r.GetPeer(), r.GetUser())
}
func main() {
authority := newCA()
serverCert := authority.issue("vault.internal", 2, true, "vault.internal")
ordersCert := authority.issue("orders-service", 3, false)
reportsCert := authority.issue("reports-service", 4, false)
otherCA := newCA()
rogueCert := otherCA.issue("orders-service", 5, false) // claims the right name but was signed by a different CA
// Server 1: TLS only. The client proves nothing; the server proves itself.
tlsOnly := credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{serverCert}, MinVersion: tls.VersionTLS13})
addr, stop := serve(tlsOnly)
clientTLS := func(name string, certs ...tls.Certificate) grpc.DialOption {
return grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{RootCAs: authority.pool, ServerName: name, Certificates: certs, MinVersion: tls.VersionTLS13}))
}
fmt.Println("== TLS: the client verifies the server")
try("plaintext client against a TLS server", addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
try("TLS client, trusted CA, right server name", addr, clientTLS("vault.internal"))
try("TLS client, wrong server name", addr, clientTLS("evil.internal"))
try("TLS client + bearer token over TLS", addr, clientTLS("vault.internal"), grpc.WithPerRPCCredentials(bearer{"ada"}))
try("bearer token over a plaintext connection", addr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithPerRPCCredentials(bearer{"ada"}))
stop()
// Server 2: mutual TLS. The server requires and verifies a client certificate, and only orders-service may call.
mtls := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{serverCert}, ClientCAs: authority.pool,
ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS13,
})
addr, stop = serve(mtls, grpc.UnaryInterceptor(onlyServices("orders-service")))
defer stop()
fmt.Println("== mTLS: the server verifies the client as well")
try("no client certificate", addr, clientTLS("vault.internal"))
try("certificate from a different CA", addr, clientTLS("vault.internal", rogueCert))
try("orders-service certificate", addr, clientTLS("vault.internal", ordersCert))
try("reports-service certificate (valid, not allowed)", addr, clientTLS("vault.internal", reportsCert))
try("orders-service certificate + user token", addr, clientTLS("vault.internal", ordersCert), grpc.WithPerRPCCredentials(bearer{"ada"}))
}mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
go run .== TLS: the client verifies the server
plaintext client against a TLS server -> Unavailable: connection error: desc = "error reading server preface: EOF"
TLS client, trusted CA, right server name -> OK peer="(no client certificate)" user="(no token)"
TLS client, wrong server name -> Unavailable: connection error: desc = "transport: authentication handshake failed: ...
TLS client + bearer token over TLS -> OK peer="(no client certificate)" user="ada"
bearer token over a plaintext connection -> dial error: grpc: the credentials require transport level security (use grpc.WithTransportCredentials() to set)
== mTLS: the server verifies the client as well
no client certificate -> Unavailable: connection error: desc = "error reading server preface: remote error: ...
certificate from a different CA -> Unavailable: connection error: desc = "error reading server preface: remote error: ...
orders-service certificate -> OK peer="orders-service" user="(no token)"
reports-service certificate (valid, not allowed) -> PermissionDenied: service "reports-service" may not call this method
orders-service certificate + user token -> OK peer="orders-service" user="ada"Server TLS
The first server uses grpc.Creds(credentials.NewTLS(&tls.Config{...})) with its certificate and a minimum of TLS 1.3. On the client, credentials.NewTLS with RootCAs (the CA to trust) and ServerName (the name to verify) replaces insecure.NewCredentials().
What the output shows, line by line:
- A plaintext client against the TLS server failed with
Unavailable: the client sent an HTTP/2 preface, the server expected a TLS handshake and closed. Misconfigured clients often fail in exactly this way, with an unhelpful "error reading server preface". - The TLS client with the trusted CA and the right name succeeded. It saw no client certificate on the server side (
peer="(no client certificate)"), as expected, because the server did not ask for one. - The wrong server name failed with
authentication handshake failed. The certificate was valid, but issued tovault.internal, and the client asked forevil.internal. Name checking is what defeats a man in the middle who has a valid certificate for some other host. Never turn it off withInsecureSkipVerify; if a test needs a different name, setServerName.
For production, use certificates from a CA your organisation trusts (an internal CA, or Let's Encrypt for public endpoints), keep the private key out of the image, and set a minimum TLS version. The Go defaults for cipher suites are sound; do not customise them without a reason.
Per-RPC credentials
grpc.WithPerRPCCredentials(creds) attaches an implementation of credentials.PerRPCCredentials to every call. It has two methods: GetRequestMetadata, which returns the metadata to add (here authorization: Bearer <token>), and RequireTransportSecurity, which says whether the credentials may only be sent over a secure connection.
The lab's bearer type returns true from RequireTransportSecurity. The effect is the second to last TLS line. Dialling a plaintext connection with these credentials fails at dial time: the credentials require transport level security. The library refuses to put a bearer token on a wire where anyone can read it. A token over plaintext is a stolen token; make this method return true for every credential that matters, and you cannot make the mistake by accident.
Over TLS the same credentials worked and the handler saw user="ada". Because they are an interface, per-RPC credentials can fetch and refresh tokens: GetRequestMetadata runs before each call, so it can return a cached token and renew it when it expires. The golang.org/x/oauth2 package provides ready-made implementations (oauth.TokenSource in google.golang.org/grpc/credentials/oauth).
On the server side, an interceptor reads the metadata, verifies the token (signature, expiry, audience for a JWT) and puts the user in the context (interceptors, and the JWT checklist in API authentication). The lab's handler just reads the header, to show the data arrived.
Mutual TLS
The second server sets ClientAuth: tls.RequireAndVerifyClientCert and ClientCAs to the CA pool. Now the handshake itself authenticates the client, and the lab shows four outcomes:
- No client certificate: rejected in the handshake. The client sees
Unavailablewith a remote error. No handler code ran. - A certificate from a different CA: the lab's
rogueCerthas the right common name,orders-service, but was signed by another authority. Rejected. This is the point of mTLS: a name is only worth trusting when a CA you trust vouches for it. orders-service's certificate: accepted. The handler reads the identity frompeer.FromContext(ctx), theAuthInfoascredentials.TLSInfo, and the firstPeerCertificatesentry's subject:peer="orders-service". This identity was proved by the handshake. It is not a header that the caller can set.reports-service's certificate: valid, trusted, and not on the allowed list. The authorisation interceptoronlyServices("orders-service")returnedPermissionDenied. Authentication answered "who", the interceptor answered "may they".
The last line combines both: the connection is mTLS as orders-service, and the call carries a user token for ada. The handler gets peer="orders-service" user="ada". A policy can now say: only the orders service may call this method, on behalf of an authenticated user, and the user's own permissions apply.
Identity in the certificate
The lab uses the common name for the service identity. Stronger designs put the identity in a URI subject alternative name (for example a SPIFFE ID such as spiffe://example.test/ns/prod/sa/orders), which is unambiguous and part of the SPIFFE standard that systems like SPIRE and many service meshes implement. Either way, authorise on a verified field of the certificate chain the library has already validated, never on something the client sends in a header.
Certificate lifecycle
Certificates expire, and the failure mode is an outage at a predictable time. Plan for it:
- Short lifetimes (hours to days for service certificates) limit the damage of a stolen key. They require automatic issuance and renewal, which tools like cert-manager, SPIRE and Vault PKI provide.
- Reload without a restart. A
tls.Configcan return certificates dynamically throughGetCertificate(server) andGetClientCertificate(client), so you can load a renewed certificate from disk when it changes. grpc-go also hascredentials/tls/certproviderfor file-watching providers. Static certificates read once at start-up turn every renewal into a deploy. - Monitor expiry. Export the number of days until expiry of every certificate you rely on and alert well in advance.
- Revocation is weak in practice. Short lifetimes work better than CRLs or OCSP for internal PKI.
When a mesh or proxy terminates TLS
In a service mesh, a sidecar proxy usually terminates mTLS for the application, and the app listens on plain localhost. The application then gets identity in a header or from the mesh's API, not from peer.TLSInfo. That is a legitimate design, as long as the application only accepts connections from the sidecar (localhost or a unix socket) and trusts the identity header only from it. A service reachable by other paths that also trusts such headers has handed the keys to anyone who can send a header. If you do not use a mesh, do mTLS in the process, as above.
Debugging handshake failures
TLS errors are terse. Useful habits: log the server name you dial and the one in the certificate (openssl s_client -connect host:port -servername name prints the chain and verification result); check the certificate's validity window and clock skew; confirm ExtKeyUsage is right for the role (a client certificate without client auth usage is rejected); and set GRPC_GO_LOG_SEVERITY_LEVEL=info to see connection state changes. If the error is error reading server preface: EOF, suspect a plaintext client talking to a TLS server (or the reverse); if it is remote error: tls: bad certificate, the server rejected your certificate.
Authorisation after authentication
Knowing who is calling is half the job. The other half is deciding what they may do, and the options form a ladder.
Per-method allow lists keyed by service identity, as the lab's onlyServices does. Cheap, explicit, easy to audit: a table from method name to the identities that may call it. It scales to dozens of services and fails the moment the table grows into something nobody reads. Keep it in one file, with a test that fails if a method is missing from it, so a new rpc starts closed.
Scopes in the token. The token carries what the user may do (orders:read, orders:write); an interceptor maps methods to required scopes. This handles user permissions without calling another service on each request.
Object-level checks in the handler. Neither of the above knows that order 42 belongs to someone else. That check needs data, so it lives with the code that loads the object (BOLA). A common failure is to feel safe after mTLS and scope checks and skip it. Every id in a request is still untrusted input.
A policy engine (OPA, Cedar, Casbin) when rules become too intricate for tables: attributes of the caller, the resource and the context. Evaluate it in an interceptor with a bounded timeout and a fail-closed default.
Whichever you choose, treat the absence of a rule as a denial. A new method that nobody wrote a policy for should be unreachable until someone decides.
Things that should stay outside the auth wall
Health checks and reflection need thought. A load balancer or the kubelet that probes the health service usually has no token, and may not hold a client certificate. Either serve health on a separate listener that is not reachable from outside the pod network, or exempt /grpc.health.v1.Health/ explicitly in the interceptor by matching info.FullMethod, and nothing else (health and reflection). Do the same consciously for reflection: off in production, or behind the same authentication as everything else.
Deadlines apply to the handshake too
A TLS handshake is part of connecting, and a slow or black-holed server makes it hang. The connection timeout is controlled by the dial options (grpc.WithConnectParams with a MinConnectTimeout) and by the context deadline of the first call. Always put a deadline on calls (deadlines) so a stalled handshake becomes an error in seconds and not a hang.
Choosing between the options
For a small system with a handful of services, start with TLS on every connection and mTLS between services, with a per-method allow list. Add user tokens when a user's identity must reach the handler. For a larger platform, move certificate issuance to a mesh or to SPIRE, keep authorisation in policy, and keep the application code to reading the verified identity. Resist inventing a custom scheme (shared secrets in metadata, signed headers of your own design); the pieces above are well understood and well supported.
Testing authentication
Test the matrix the lab runs, as a table: no TLS, wrong name, no certificate, wrong CA, allowed identity, valid but unauthorised identity, with and without a token. The in-memory CA makes this a few lines of setup. Add a test that every method of the server is covered by the authorisation interceptor, including streaming methods (the gap described in the interceptors article), and a test that the health service stays reachable if probes depend on it. The mechanisms are documented in the gRPC authentication guide and in the Go crypto/tls package.


