Securing Microservices: JWT, mTLS, and Centralized Authorization via REST API in Kubernetes
Introduction: Why Microservice Security Is More Complex Than Monoliths
By 2026, microservice architecture had become the standard for high-load systems — but along with flexibility came a fundamentally different threat model. In a monolith, the security perimeter was clear: one process, one database, one set of permissions. In microservices, the attack surface is distributed: dozens of services exchange data over the network, and each one is a potential entry point.
Current threats to microservice systems running in Kubernetes include:
- Lateral movement — compromising one service opens access to the cluster's internal network
- Token leakage — interception of JWT or session tokens via unencrypted traffic between pods
- Privilege escalation — a service gains more permissions than necessary due to misconfigured authorization
- SSRF attacks — an attacker exploits inter-service trust to reach internal resources
- Supply chain attacks — a compromised dependency in one service affects the entire ecosystem
The answer to these threats is a multi-layered defense: user authentication via JWT, mutual service authentication via mTLS, and centralized authorization through a Policy Decision Point. Let's examine each layer in detail.
JWT in Microservices: Stateless Authentication and Gateway-Level Validation
JWT Authentication Architecture
JSON Web Tokens enable stateless authentication: the server stores no sessions — all information is encoded within the token itself and verified cryptographically. In a microservice context, this means each service can independently verify the authenticity of a request without consulting a central store.
A typical interaction flow looks like this:
Client → [POST /auth/login] → Auth Service
↓
Issues JWT (access + refresh)
↓
Client → [GET /api/orders] → API Gateway
↓
Validates JWT (signature, exp, iss)
↓
Proxies request → Order Service
↓
Order Service trusts gateway (mTLS)
JWT Validation at the API Gateway Level
JWT validation should be performed at the API Gateway level, not within each individual microservice. This eliminates logic duplication and centralizes control. The gateway verifies:
- Token signature (algorithm RS256 or ES256 — never
none) - Token lifetime (
expclaim) - Issuer (
issclaim) - Audience (
audclaim) - Whether the token appears in the revocation list (Redis)
Example Go middleware for JWT validation using the golang-jwt/jwt library:
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
type Claims struct {
UserID string `json:"sub"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
type JWTMiddleware struct {
publicKey interface{}
redisClient *redis.Client
}
func NewJWTMiddleware(publicKey interface{}, rc *redis.Client) *JWTMiddleware {
return &JWTMiddleware{publicKey: publicKey, redisClient: rc}
}
func (m *JWTMiddleware) Validate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "missing or invalid Authorization header", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
// Explicitly verify the signing algorithm
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.publicKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Check revocation list in Redis
ctx := context.Background()
revoked, err := m.redisClient.Exists(ctx, "revoked:"+tokenString[:16]).Result()
if err != nil || revoked > 0 {
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
// Pass claims downstream via context
ctx = context.WithValue(r.Context(), "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Key Rotation
Signing key rotation uses the JWKS (JSON Web Key Set) mechanism. The Auth Service publishes public keys at the /.well-known/jwks.json endpoint, and the Gateway caches them with a TTL and periodically refreshes them. During rotation, the old key remains available until all issued tokens have expired — this prevents service disruption during planned key changes.
mTLS Between Services: Encryption and Mutual Authentication Without a Service Mesh
Why mTLS in Microservices
One-way TLS only authenticates the server. Mutual TLS (mTLS) requires both parties to present a certificate: the client proves its identity to the server, and vice versa. In a Kubernetes context, this means that even if an attacker gains access to the cluster network, they cannot impersonate a legitimate service without a valid client certificate.
Without a service mesh (Istio, Linkerd), mTLS is implemented at the application level. Each Go service is configured to use a client certificate for outgoing requests and to require a client certificate from incoming ones.
Implementing an mTLS Client in Go
package transport
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
"time"
)
// NewMTLSClient creates an HTTP client with mutual TLS authentication
func NewMTLSClient(certFile, keyFile, caFile string) (*http.Client, error) {
// Load client certificate and key
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load client cert: %w", err)
}
// Load CA for server verification
caCert, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to append CA cert")
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
MinVersion: tls.VersionTLS13, // Minimum TLS 1.3
}
transport := &http.Transport{
TLSClientConfig: tlsConfig,
// Connection pool settings for production
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}, nil
}
Configuring an mTLS Server in Go
package server
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
)
func NewMTLSServer(certFile, keyFile, caFile string, handler http.Handler) (*http.Server, error) {
caCert, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert, // Mandatory client verification
ClientCAs: caCertPool,
MinVersion: tls.VersionTLS13,
}
return &http.Server{
Addr: ":8443",
Handler: handler,
TLSConfig: tlsConfig,
}, nil
}
mTLS interaction flow:
Order Service (client) Inventory Service (server)
│ │
│── ClientHello (TLS 1.3) ──────────────→ │
│← ServerHello + ServerCert ───────────── │
│── ClientCert ────────────────────────→ │
│ (Verification against CA) ←────────── │
│← Verified (200 OK) ──────────────────── │
│ │
Each service knows exactly who it's talking to
Centralized Authorization Service: Policy Decision Point and Open Policy Agent
The Policy Decision Point Pattern
In a distributed system, authorization logic must not be scattered across microservices. The Policy Decision Point (PDP) pattern moves authorization decisions into a dedicated component. Services send a query — "can user X perform action Y on resource Z?" — and receive an allow or deny response.
Open Policy Agent (OPA) is the de facto standard for implementing a PDP in the Kubernetes ecosystem. Policies are written in the Rego language, making them versionable, testable, and independent of application code.
Example Rego Policy
package authz.orders
import future.keywords.if
import future.keywords.in
# Allow reading orders for the owner or a manager
default allow := false
allow if {
input.action == "read"
input.resource.owner_id == input.user.id
}
allow if {
input.action == "read"
"manager" in input.user.roles
}
# Only admins can delete orders
allow if {
input.action == "delete"
"admin" in input.user.roles
}
Integration with a Go Service via REST API
package authz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type OPAClient struct {
baseURL string
httpClient *http.Client
}
type AuthzRequest struct {
Input AuthzInput `json:"input"`
}
type AuthzInput struct {
User UserContext `json:"user"`
Action string `json:"action"`
Resource ResourceContext `json:"resource"`
}
type AuthzResponse struct {
Result bool `json:"result"`
}
func (c *OPAClient) IsAllowed(ctx context.Context, input AuthzInput) (bool, error) {
payload, err := json.Marshal(AuthzRequest{Input: input})
if err != nil {
return false, err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/v1/data/authz/orders/allow",
bytes.NewReader(payload),
)
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return false, fmt.Errorf("OPA request failed: %w", err)
}
defer resp.Body.Close()
var result AuthzResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, err
}
return result.Result, nil
}
Practical Implementation: Go Service and Laravel as a Consumer
Go Service with JWT Middleware and mTLS
The complete request processing chain in a Go service looks like this:
package main
import (
"log"
"net/http"
"github.com/yourorg/service/internal/authz"
"github.com/yourorg/service/internal/middleware"
"github.com/yourorg/service/internal/server"
"github.com/yourorg/service/internal/transport"
)
func main() {
// Initialize mTLS HTTP client for outgoing requests
mtlsClient, err := transport.NewMTLSClient(
"/etc/certs/client.crt",
"/etc/certs/client.key",
"/etc/certs/ca.crt",
)
if err != nil {
log.Fatalf("failed to create mTLS client: %v", err)
}
// OPA client uses mTLS to communicate with the AuthZ service
opaClient := authz.NewOPAClient("https://opa.internal:8443", mtlsClient)
// Routing with JWT middleware
mux := http.NewServeMux()
jwtMW := middleware.NewJWTMiddleware(loadPublicKey(), initRedis())
mux.Handle("/api/orders", jwtMW.Validate(
authzMiddleware(opaClient, "read",
http.HandlerFunc(handleGetOrders),
),
))
// Start mTLS server
srv, err := server.NewMTLSServer(
"/etc/certs/server.crt",
"/etc/certs/server.key",
"/etc/certs/ca.crt",
mux,
)
if err != nil {
log.Fatalf("failed to create server: %v", err)
}
log.Fatal(srv.ListenAndServeTLS("", ""))
}
Laravel Service as a Consumer
The Laravel service communicates with Go services via REST API, using client certificates for mTLS. HTTP client configuration in Laravel via Guzzle:
// config/services.php
'order_service' => [
'base_url' => env('ORDER_SERVICE_URL', 'https://order-service.internal:8443'),
'cert' => env('MTLS_CERT_PATH', '/etc/certs/client.crt'),
'key' => env('MTLS_KEY_PATH', '/etc/certs/client.key'),
'ca' => env('MTLS_CA_PATH', '/etc/certs/ca.crt'),
],
// app/Services/OrderServiceClient.php
class OrderServiceClient
{
private Client $client;
public function __construct()
{
$config = config('services.order_service');
$this->client = new Client([
'base_uri' => $config['base_url'],
'cert' => [$config['cert'], ''], // path to certificate
'ssl_key' => $config['key'],
'verify' => $config['ca'], // CA for server verification
'timeout' => 5.0,
]);
}
public function getOrders(string $jwtToken): array
{
$response = $this->client->get('/api/orders', [
'headers' => [
'Authorization' => 'Bearer ' . $jwtToken,
'X-Request-ID' => (string) Str::uuid(),
],
]);
return json_decode($response->getBody()->getContents(), true);
}
}
Laravel passes the user's JWT when calling internal services. This allows downstream services to know the user context without requiring re-authentication.
Certificate Management in Kubernetes: cert-manager and Automatic Rotation
PKI Architecture in the Cluster
cert-manager is used for TLS certificate management in Kubernetes. It automatically issues and renews certificates, stores them as Kubernetes Secrets, and mounts them into pods.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: order-service-cert
namespace: production
spec:
secretName: order-service-tls
duration: 24h # Short TTL for mTLS certificates
renewBefore: 8h # Renew 8 hours before expiry
subject:
organizations:
- your-org
commonName: order-service.production.svc.cluster.local
dnsNames:
- order-service
- order-service.production
- order-service.production.svc
- order-service.production.svc.cluster.local
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
usages:
- digital signature
- key encipherment
- client auth # For mTLS: certificate used as a client cert
- server auth
Mounting Certificates into a Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: order-service
image: your-registry/order-service:latest
volumeMounts:
- name: tls-certs
mountPath: /etc/certs
readOnly: true
env:
- name: TLS_CERT_PATH
value: /etc/certs/tls.crt
- name: TLS_KEY_PATH
value: /etc/certs/tls.key
- name: TLS_CA_PATH
value: /etc/certs/ca.crt
volumes:
- name: tls-certs
secret:
secretName: order-service-tls
When a certificate is rotated, cert-manager updates the Secret and Kubernetes remounts the files into the running container. The Go service should support hot certificate reloading via tls.Config.GetCertificate instead of loading certificates statically at startup.
Token Storage and Transmission: Redis as a Revocation List
The JWT Revocation Problem
JWT is inherently stateless: a valid token cannot be "revoked" without an additional mechanism. If a user logs out, changes their password, or their account is compromised, there must be a way to immediately invalidate issued tokens before they expire.
Redis-Based Revocation List
The solution is to store revoked token identifiers in Redis with a TTL equal to the token's remaining lifetime. The Gateway checks every incoming token against this list.
package token
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type RevocationStore struct {
client *redis.Client
}
const revokedKeyPrefix = "token:revoked:"
// RevokeToken adds the token's JTI to the revocation list
func (s *RevocationStore) RevokeToken(ctx context.Context, jti string, expiresAt time.Time) error {
ttl := time.Until(expiresAt)
if ttl <= 0 {
// Token already expired — nothing to revoke
return nil
}
return s.client.Set(ctx, revokedKeyPrefix+jti, "1", ttl).Err()
}
// IsRevoked checks whether the token has been revoked
func (s *RevocationStore) IsRevoked(ctx context.Context, jti string) (bool, error) {
result, err := s.client.Exists(ctx, revokedKeyPrefix+jti).Result()
if err != nil {
return false, err
}
return result > 0, nil
}
For scalability, a Redis Cluster with replication ensures revocation list availability even when nodes fail. Use the WAIT command to confirm replication when revoking critical tokens.
Logging and Auditing Security Events
What to Log
Security auditing in microservices should cover the following events:
- Every authentication attempt (successful and failed), including User-Agent, IP, and timestamp
- OPA authorization denials with context: who tried to do what, and to which resource
- mTLS handshake errors: invalid certificate, expired certificate, unknown CA
- Token revocations: who initiated it, which JTI, and the reason
- JWT signing key rotations
- Changes to OPA policies (via CI/CD)
Security Event Structure
{
"timestamp": "2026-03-15T14:32:01.123Z",
"level": "WARN",
"event_type": "authz.denied",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"service": "order-service",
"user_id": "usr_01HX4M",
"action": "delete",
"resource_type": "order",
"resource_id": "ord_09KL2P",
"policy": "authz.orders",
"decision": "deny",
"client_cert_cn": "inventory-service",
"source_ip": "10.0.1.45"
}
Structured logs in JSON format are sent to a centralized system (ELK, Loki, CloudWatch). Configure alerts for anomalies: a sudden spike in authz.denied events, repeated mTLS errors from the same IP, or attempts to use revoked tokens.
Common Vulnerabilities and How to Avoid Them
Token Leakage
JWT is passed in the Authorization header and can be compromised when:
- Full HTTP headers are logged — never log the
Authorizationheader in its entirety - The token is passed as a URL parameter — it ends up in server access logs and browser history
- Traffic between services is unencrypted — this is exactly why mTLS is necessary
Solution: log only the first 8 characters of the token (for correlation), use exclusively HTTPS/mTLS, and set a short TTL (15 minutes for access tokens).
SSRF via Inter-Service Trust
If service A trusts all requests from service B based on mTLS, an attacker who has compromised B can exploit that trust to launch SSRF attacks against internal resources. Countermeasures:
- mTLS confirms the identity of a service, but not the intent of a request — OPA authorization is mandatory for every action
- Network policies in Kubernetes: services may only communicate with explicitly whitelisted addresses
- Validate all incoming URLs in request parameters against a whitelist
Privilege Escalation via Claims
Never trust claims from a JWT without verifying them in the context of the current operation. A common mistake: a service accepts the admin role directly from a token without checking through OPA, which allows payload forgery if a weak signing algorithm is used. Solutions:
- Use only asymmetric algorithms (RS256, ES256) — never HS256 in a multi-service environment where every service would need to know the secret
- Validate the algorithm explicitly in code; do not trust the
algheader from the token - OPA receives claims from the token, but the final decision is based on up-to-date data from the database
Conclusion: Microservice Security Checklist
Comprehensive protection of a microservice architecture is not a collection of independent tools — it is an interconnected system. JWT establishes user identity, mTLS guarantees service identity, OPA centralizes authorization logic, cert-manager automates certificate management, and Redis provides a token revocation mechanism.
Checklist for a production-ready system:
- JWT is signed with RS256 or ES256; the algorithm is validated explicitly in code
- Access token TTL is no more than 15 minutes; refresh token TTL is no more than 7 days
- Public keys are published via JWKS and cached with auto-renewal
- All inter-service traffic is protected by mTLS with a minimum TLS 1.3 version
- Certificates are managed by cert-manager with automatic rotation
- Authorization is centralized via OPA; policies are versioned in Git
- The revocation list is stored in Redis with TTL equal to the token's remaining lifetime
- All security events are logged in structured JSON format
- Network policies in Kubernetes restrict inter-service communication to a whitelist
Authorizationheaders are excluded from application logs
Implementing each of these layers requires engineering effort, but it is their combination that provides defence-in-depth — a situation where compromising one component does not mean compromising the entire system.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →