Definition
- mTLS – both client and server present X.509 certificates; a client without a valid cert is rejected at the TLS handshake.
- JWT auth interceptor – a
ServerInterceptorthat reads theAuthorizationmetadata key, validates the token, and rejects withUNAUTHENTICATED. - Deadlines – per-call time budgets set with
withDeadlineAfterthat propagate across hops and fail withDEADLINE_EXCEEDED. - Compression – gzip per call with
withCompression("gzip")to shrink payloads. - Keep-alive – HTTP/2 PING frames on idle connections to detect dead peers; configure
keepAliveTimeandkeepAliveTimeout.
Think of National Bank of India's armored-van route between branches. mTLS is both the driver and the branch checking each other's ID badges. The JWT interceptor is the vault guard checking every visitor's signed pass. The deadline is the rule that a request older than two seconds is abandoned. Compression folds the paperwork into a compact pouch. Keep-alive is the periodic radio check so a dead line is noticed quickly.
Understanding the Concepts
How do you configure mTLS for gRPC in Spring Boot?
With grpc-spring-boot-starter, TLS and mTLS are configured in application.yml. You give the server a certificate chain and key, and to require client certificates you set clientAuth: REQUIRE with a trust collection.
# application.yml
grpc:
server:
port: 9090
security:
enabled: true
certificateChain: classpath:certs/server.crt
privateKey: classpath:certs/server.key
clientAuth: REQUIRE
trustCertCollection: classpath:certs/ca.crt
client:
account-service:
address: static://account-service:9090
security:
clientAuthEnabled: true
certificateChain: classpath:certs/client.crt
privateKey: classpath:certs/client.key
trustCertCollection: classpath:certs/ca.crt
A client without a valid certificate is rejected at the TLS handshake, before any application code runs.
How do you write a JWT auth interceptor?
The JWT interceptor is a ServerInterceptor. It reads the Authorization metadata key, strips the Bearer prefix, validates the token, and closes the call with UNAUTHENTICATED if invalid.
package com.bank.grpc;
import io.grpc.*;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
public class JwtAuthInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> AUTH_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private static final Context.Key<String> USER_CTX =
Context.key("authenticated-user");
private final SecretKey signingKey;
public JwtAuthInterceptor(String secret) {
this.signingKey = Keys.hmacShaKeyFor(
secret.getBytes(StandardCharsets.UTF_8));
}
@Override
public <Req, Resp> ServerCall.Listener<Req> interceptCall(
ServerCall<Req, Resp> call,
Metadata headers,
ServerCallHandler<Req, Resp> next) {
String token = headers.get(AUTH_KEY);
if (token == null || !token.startsWith("Bearer ")) {
call.close(Status.UNAUTHENTICATED
.withDescription("Missing or invalid token"),
new Metadata());
return new ServerCall.Listener<>() {};
}
try {
Claims claims = Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token.substring(7))
.getPayload();
String subject = claims.getSubject();
Context ctx = Context.current().withValue(USER_CTX, subject);
return Contexts.interceptCall(ctx, call, headers, next);
} catch (Exception e) {
call.close(Status.UNAUTHENTICATED
.withDescription("Token validation failed: " + e.getMessage()),
new Metadata());
return new ServerCall.Listener<>() {};
}
}
}
How do deadlines work and why do they propagate?
Deadlines are set by the client with withDeadlineAfter. The deadline is an absolute time that travels in the call metadata. If it passes, the call fails with DEADLINE_EXCEEDED. Crucially, the deadline propagates to downstream gRPC calls through the context, so the entire chain respects one time budget.
package com.bank.service;
import com.bank.grpc.AccountServiceGrpc;
import com.bank.grpc.BalanceRequest;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import java.util.concurrent.TimeUnit;
public class TransferClient {
public static void main(String[] args) {
ManagedChannel channel = ManagedChannelBuilder
.forAddress("account-service", 9090)
.usePlaintext()
.build();
var stub = AccountServiceGrpc.newBlockingStub(channel)
.withDeadlineAfter(2, TimeUnit.SECONDS); // 2-second deadline
var request = BalanceRequest.newBuilder()
.setAccountId("RAJESH-001").build();
// If the server takes longer than 2 seconds,
// the call fails with DEADLINE_EXCEEDED
var response = stub.getBalance(request);
System.out.println("Balance: " + response.getBalancePaise());
}
}
This is different from a client-only timeout because the server also learns the deadline and can stop work early.
How do you enable compression?
Compression is enabled per call with withCompression. For large responses like account statements, gzip can cut payload size dramatically.
var stub = AccountServiceGrpc.newBlockingStub(channel)
.withCompression("gzip")
.withDeadlineAfter(5, TimeUnit.SECONDS);
// The server advertises decompression support
// Responses are also compressed if the server enables it
How do you configure keep-alive?
Keep-alive sends HTTP/2 PING frames on idle connections. If a ping is not answered within the timeout, the connection is closed and a new one is created.
ManagedChannel channel = ManagedChannelBuilder
.forAddress("account-service", 9090)
.keepAliveTime(30, TimeUnit.SECONDS) // Send ping every 30s idle
.keepAliveTimeout(5, TimeUnit.SECONDS) // Close if no pong in 5s
.keepAliveWithoutCalls(true) // Ping even with no active RPCs
.build();
On the server side, align permitKeepAliveTime with the client's ping interval. If the server's allowed minimum is higher than the client's interval, the server sends GOAWAY and kills the connection.
Server server = ServerBuilder.forPort(9090)
.addService(new AccountServiceImpl())
.permitKeepAliveTime(20, TimeUnit.SECONDS) // Allow pings >= 20s
.keepAliveTime(60, TimeUnit.SECONDS)
.build();
How do these concerns work together?
| Concern | Where configured | Effect |
|---------|-----------------|--------|
| mTLS | application.yml | Both sides prove identity; channel encrypted |
| JWT auth | ServerInterceptor | Per-request user authorization |
| Deadline | Client stub | Time budget propagates across hops |
| Compression | Client stub | Shrinks payloads |
| Keep-alive | Channel builder + server | Detects dead connections |
What are common mistakes?
The most common mistake is not setting deadlines. Without them, a stuck downstream can exhaust threads and connections.
// WRONG: no deadline
var stub = AccountServiceGrpc.newBlockingStub(channel);
// CORRECT: always set a deadline in production
var stub = AccountServiceGrpc.newBlockingStub(channel)
.withDeadlineAfter(2, TimeUnit.SECONDS);
Another mistake is misaligned keep-alive settings. If the client pings every 10 seconds but the server's permitKeepAliveTime is 30 seconds, the server sends GOAWAY.
What should a senior engineer know?
Every call in production should have a deadline. mTLS should be non-negotiable for service-to-service calls in banking. JWT tokens carry user identity for per-request authorization on top of mTLS channel authentication. Compression matters for large payloads but adds CPU overhead on both sides. Keep-alive alignment between client and server prevents silent connection drops.
Visual Overview
gRPC Hardening in Spring Boot: Client Server ┌──────────────┐ ┌──────────────┐ │ mTLS cert │──TLS──────────▶│ mTLS verify │ │ JWT token │──metadata─────▶│ JWT intercept│ │ deadline(2s) │──propagates───▶│ respects 2s │ │ gzip │──compressed───▶│ decompresses │ │ keepAlive │──PING─────────▶│ PONG │ └──────────────┘ └──────────────┘ Timeline: 1. TLS handshake (mTLS: both certs checked) 2. JWT validated in ServerInterceptor 3. Deadline countdown starts 4. Request/response compressed with gzip 5. Idle connection kept alive with PING/PONG
Key Takeaway
Hardening gRPC in Spring Boot is five independent concerns configured together. mTLS makes both client and server present X.509 certificates, so identity is proven and the channel encrypted at the TLS handshake, before any application code runs. The JWT interceptor reads the Authorization key from metadata, validates the token, and closes the call with UNAUTHENTICATED if invalid, providing per-request user authorization on top of mTLS channel authentication.
Deadlines are set by the client with withDeadlineAfter, and the key property is that the deadline is absolute and propagates across hops. The entire chain respects one time budget, preventing cascading hangs. Compression with gzip matters for large responses like account statements. Keep-alive pings detect dead connections, but the client's interval must align with the server's permitKeepAliveTime or the server sends GOAWAY.
At National Bank of India, Pankaj's transfer service used all five together. mTLS stopped impersonation, the JWT interceptor authorized each user request, every call carried a two-second deadline, statements were gzipped, and keep-alive kept the connection pool warm. Every call in production should have a deadline, because without one a stuck downstream can exhaust threads.
Remember one sentence: mTLS authenticates the channel, JWT authorizes the user, deadlines propagate a time budget, gzip compresses payloads, and keep-alive detects dead peers, and together they make gRPC production-ready.
---