API Gateway Authentication: JWT, API Keys, mTLS, and Kong Patterns
Implement production API gateway authentication: JWT validation at the gateway, API key management with scopes, mutual TLS for service-to-service, and Kong plugin configuration with Terraform.
Moving authentication logic into the API gateway layer has a compounding effect: every service behind the gateway gets auth enforcement without implementing it. Rotated keys propagate instantly. Audit logs centralize. Rate limits apply uniformly. The tradeoff is that your gateway becomes a critical failure point — it needs the same reliability SLA as your database.
This post covers four authentication patterns at the gateway layer: JWT validation, API key management with scopes, mutual TLS for service-to-service traffic, and the Kong plugin configuration that implements each.
Authentication Layers
Client ──────────────────────────► API Gateway
│
┌─────────────────────┤ Auth layer
│ │
▼ ▼
JWT (end-users) API Key (3rd-party / B2B)
mTLS (service-to-service) OAuth2 (partner integrations)
│
▼
Upstream Services (receive verified identity headers)
X-User-Id, X-Tenant-Id, X-Scopes
The gateway validates credentials and forwards identity as headers. Upstream services trust these headers (enforce network policy: only gateway can reach services).
1. JWT Validation at the Gateway
JWT Strategy: Stateless vs. Introspection
| Strategy | Latency | Revocation | Use Case |
|---|---|---|---|
| Stateless (verify signature only) | ~0ms | No (must wait for expiry) | Short-lived tokens (15 min) |
| Redis cache (store valid JTIs) | ~1ms | Yes (delete from cache) | Medium-lived tokens (1 hour) |
| Introspection endpoint | 5–20ms | Yes (immediate) | Long-lived tokens, OAuth2 |
Our recommendation: Stateless validation with 15-minute token TTL + Redis blocklist for revoked tokens (logout, password change).
JWT Issuer Service
// src/services/auth/token.service.ts
import * as jose from 'jose';
import { redis } from '../../lib/redis';
import crypto from 'crypto';
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes
const REFRESH_TOKEN_TTL = 7 * 24 * 3600; // 7 days
interface TokenPayload {
sub: string; // userId
tid: string; // tenantId
email: string;
roles: string[];
scopes: string[];
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
// Load RSA keys (use RS256 so gateways can verify without the private key)
const privateKey = await jose.importPKCS8(process.env.JWT_PRIVATE_KEY!, 'RS256');
const publicKey = await jose.importSPKI(process.env.JWT_PUBLIC_KEY!, 'RS256');
export async function issueTokenPair(payload: TokenPayload): Promise<TokenPair> {
const jti = crypto.randomUUID();
const accessToken = await new jose.SignJWT({
...payload,
jti,
iss: 'https://auth.viprasol.com',
aud: 'https://api.viprasol.com',
})
.setProtectedHeader({ alg: 'RS256', kid: 'v1' })
.setIssuedAt()
.setExpirationTime(`${ACCESS_TOKEN_TTL}s`)
.sign(privateKey);
const refreshToken = crypto.randomBytes(32).toString('hex');
// Store refresh token in Redis (hashed)
const hashedRefresh = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
await redis.setEx(
`refresh:${hashedRefresh}`,
REFRESH_TOKEN_TTL,
JSON.stringify({ userId: payload.sub, tenantId: payload.tid, jti })
);
return { accessToken, refreshToken, expiresIn: ACCESS_TOKEN_TTL };
}
export async function verifyAccessToken(
token: string
): Promise<TokenPayload & { jti: string }> {
const { payload } = await jose.jwtVerify(token, publicKey, {
issuer: 'https://auth.viprasol.com',
audience: 'https://api.viprasol.com',
});
const jti = payload.jti as string;
// Check blocklist (revoked tokens)
const isRevoked = await redis.exists(`blocklist:jti:${jti}`);
if (isRevoked) throw new Error('Token has been revoked');
return payload as TokenPayload & { jti: string };
}
export async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
// Add to blocklist until natural expiry
await redis.setEx(`blocklist:jti:${jti}`, ttlSeconds, '1');
}
// Expose public key as JWKS for gateway validation
export async function getJwks(): Promise<{ keys: object[] }> {
const jwk = await jose.exportJWK(publicKey);
return {
keys: [{ ...jwk, use: 'sig', alg: 'RS256', kid: 'v1' }],
};
}
JWKS Endpoint (for Gateway Discovery)
// src/app/api/.well-known/jwks.json/route.ts
import { NextResponse } from 'next/server';
import { getJwks } from '../../../../services/auth/token.service';
export async function GET() {
const jwks = await getJwks();
return NextResponse.json(jwks, {
headers: {
'Cache-Control': 'public, max-age=3600', // Gateways cache JWKS
},
});
}
☁️ Is Your Cloud Costing Too Much?
Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.
- AWS, GCP, Azure certified engineers
- Infrastructure as Code (Terraform, CDK)
- Docker, Kubernetes, GitHub Actions CI/CD
- Typical audit recovers $500–$3,000/month in savings
2. API Key Management
API keys are for machine-to-machine auth: webhooks, partner integrations, public APIs. They need scopes (what can the key do?), expiry, and rotation support.
API Key Schema
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
description TEXT,
-- The key itself is stored hashed — we show it once on creation
key_prefix TEXT NOT NULL, -- e.g., 'vp_live_' for display/identification
key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the full key
scopes TEXT[] NOT NULL DEFAULT '{}',
allowed_ips INET[], -- NULL = allow all IPs
last_used_at TIMESTAMPTZ,
last_used_ip INET,
usage_count BIGINT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true,
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
INDEX idx_api_keys_tenant (tenant_id, is_active) WHERE is_active = true,
INDEX idx_api_keys_hash (key_hash)
);
API Key Generation and Validation
// src/services/auth/api-key.service.ts
import crypto from 'crypto';
import { db } from '../../lib/db';
import { redis } from '../../lib/redis';
export type ApiKeyScope =
| 'read:data'
| 'write:data'
| 'read:reports'
| 'manage:users'
| 'webhooks:send'
| 'admin';
interface CreateApiKeyInput {
tenantId: string;
createdBy: string;
name: string;
description?: string;
scopes: ApiKeyScope[];
allowedIps?: string[];
expiresInDays?: number;
}
interface ApiKeyResult {
keyId: string;
rawKey: string; // Show ONCE — never stored in plaintext
prefix: string;
}
export async function createApiKey(input: CreateApiKeyInput): Promise<ApiKeyResult> {
// Generate: vp_live_<32 random bytes hex>
const environment = process.env.NODE_ENV === 'production' ? 'live' : 'test';
const randomBytes = crypto.randomBytes(32).toString('hex');
const rawKey = `vp_${environment}_${randomBytes}`;
const prefix = rawKey.slice(0, 12); // 'vp_live_xxxx'
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const expiresAt = input.expiresInDays
? new Date(Date.now() + input.expiresInDays * 86_400_000)
: null;
const created = await db.apiKey.create({
data: {
tenantId: input.tenantId,
createdBy: input.createdBy,
name: input.name,
description: input.description,
keyPrefix: prefix,
keyHash,
scopes: input.scopes,
allowedIps: input.allowedIps ?? [],
expiresAt,
},
});
return { keyId: created.id, rawKey, prefix };
}
export interface ValidatedApiKey {
keyId: string;
tenantId: string;
scopes: ApiKeyScope[];
}
// Cache validated keys for 60 seconds to avoid DB hit on every request
const KEY_CACHE_TTL = 60;
export async function validateApiKey(
rawKey: string,
requiredScope?: ApiKeyScope,
requestIp?: string
): Promise<ValidatedApiKey> {
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const cacheKey = `apikey:${keyHash}`;
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
const data = JSON.parse(cached) as ValidatedApiKey;
if (requiredScope && !data.scopes.includes(requiredScope) && !data.scopes.includes('admin')) {
throw new Error(`Missing required scope: ${requiredScope}`);
}
return data;
}
const key = await db.apiKey.findUnique({ where: { keyHash } });
if (!key || !key.isActive) throw new Error('Invalid API key');
if (key.revokedAt) throw new Error('API key has been revoked');
if (key.expiresAt && key.expiresAt < new Date()) throw new Error('API key has expired');
// IP allowlist check
if (key.allowedIps.length > 0 && requestIp) {
const allowed = key.allowedIps.some((ip) => ip.toString() === requestIp);
if (!allowed) throw new Error('Request IP not in allowlist');
}
if (requiredScope && !key.scopes.includes(requiredScope) && !key.scopes.includes('admin')) {
throw new Error(`Missing required scope: ${requiredScope}`);
}
// Update usage stats (async, non-blocking)
db.apiKey.update({
where: { id: key.id },
data: {
lastUsedAt: new Date(),
lastUsedIp: requestIp,
usageCount: { increment: 1 },
},
}).catch(console.error);
const result: ValidatedApiKey = {
keyId: key.id,
tenantId: key.tenantId,
scopes: key.scopes as ApiKeyScope[],
};
// Cache for 60 seconds
await redis.setEx(cacheKey, KEY_CACHE_TTL, JSON.stringify(result));
return result;
}
export async function revokeApiKey(keyId: string, revokedBy: string): Promise<void> {
const key = await db.apiKey.update({
where: { id: keyId },
data: { isActive: false, revokedAt: new Date(), revokedBy },
});
// Immediately invalidate cache
const cacheKey = `apikey:${key.keyHash}`;
await redis.del(cacheKey);
}
3. Mutual TLS (mTLS) for Service-to-Service
mTLS requires both client and server to present certificates. It's the recommended pattern for service mesh communication — no API keys to rotate, no JWTs to expire mid-request.
Certificate Authority Setup
# Generate private CA (store private key in AWS Secrets Manager / Vault)
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/CN=viprasol-internal-ca/O=Viprasol Tech"
# Generate service certificate (repeat for each service)
SERVICE=payment-service
openssl genrsa -out ${SERVICE}.key 2048
openssl req -new -key ${SERVICE}.key -out ${SERVICE}.csr \
-subj "/CN=${SERVICE}/O=Viprasol Tech"
openssl x509 -req -days 365 -in ${SERVICE}.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out ${SERVICE}.crt
Kong mTLS Plugin Configuration
# kong/plugins/mtls-auth.yaml
_format_version: "3.0"
plugins:
- name: mtls-auth
config:
ca_certificates:
- ca_id: "internal-ca-uuid" # Reference to CA cert uploaded to Kong
skip_consumer_lookup: false
authenticated_group_by: "CN" # Group by Common Name
revocation_check_mode: SKIP # Or IGNORE_CA_ERROR for OCSP
# Route-level: require mTLS for internal service routes
routes:
- name: payment-internal
paths:
- /internal/payments
strip_path: false
plugins:
- name: mtls-auth
config:
ca_certificates:
- ca_id: "internal-ca-uuid"
Node.js Service: Present Client Certificate
// src/lib/http/internal-client.ts
import https from 'https';
import fs from 'fs';
import axios from 'axios';
// Load service certificate at startup
const serviceAgent = new https.Agent({
cert: fs.readFileSync(process.env.SERVICE_CERT_PATH!),
key: fs.readFileSync(process.env.SERVICE_KEY_PATH!),
ca: fs.readFileSync(process.env.CA_CERT_PATH!),
rejectUnauthorized: true, // Always verify server cert
});
export const internalClient = axios.create({
httpsAgent: serviceAgent,
baseURL: process.env.INTERNAL_API_BASE_URL,
timeout: 10_000,
headers: {
'Content-Type': 'application/json',
},
});
// Usage:
// const result = await internalClient.post('/internal/payments/process', { ... });
⚙️ DevOps Done Right — Zero Downtime, Full Automation
Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.
- Staging + production environments with feature flags
- Automated security scanning in the pipeline
- Uptime monitoring + alerting + runbook automation
- On-call support handover docs included
4. Kong Gateway Configuration (Terraform)
# infrastructure/kong/main.tf
terraform {
required_providers {
kong = {
source = "kevholditch/kong"
version = "~> 6.0"
}
}
}
provider "kong" {
kong_admin_uri = var.kong_admin_url
}
# JWT plugin for user-facing APIs
resource "kong_plugin" "jwt_auth" {
name = "jwt"
config_json = jsonencode({
uri_param_names = ["jwt"]
header_names = ["Authorization"]
claims_to_verify = ["exp", "nbf"]
maximum_expiration = 3600 # Reject tokens valid > 1 hour
key_claim_name = "iss" # Use issuer as key identifier
secret_is_base64 = false
run_on_preflight = false
})
}
# API key plugin
resource "kong_plugin" "api_key_auth" {
name = "key-auth"
config_json = jsonencode({
key_names = ["X-API-Key", "api_key"]
key_in_header = true
key_in_query = false # Avoid keys in URLs (logged by servers)
key_in_body = false
hide_credentials = true # Don't forward key to upstream
})
}
# Rate limiting per consumer
resource "kong_plugin" "rate_limit_per_consumer" {
name = "rate-limiting"
config_json = jsonencode({
minute = 1000 # Default: 1000 req/min
hour = 10000
policy = "redis"
redis_host = var.redis_host
redis_port = 6379
redis_password = var.redis_password
fault_tolerant = true # Allow requests if Redis is down
hide_client_headers = false
error_code = 429
error_message = "API rate limit exceeded"
})
}
# Request transformer: add identity headers from JWT
resource "kong_plugin" "add_identity_headers" {
name = "request-transformer"
config_json = jsonencode({
add = {
headers = [
"X-Consumer-Id:$(consumer.id)",
"X-Consumer-Custom-Id:$(consumer.custom_id)",
]
}
remove = {
headers = ["Authorization"] # Strip JWT before forwarding to upstream
}
})
}
# Service and route
resource "kong_service" "api" {
name = "main-api"
url = "http://api-service:3000"
retries = 3
protocol = "http"
connect_timeout = 5000
read_timeout = 30000
write_timeout = 30000
}
resource "kong_route" "api_v1" {
name = "api-v1"
protocols = ["https"]
paths = ["/api/v1"]
service {
id = kong_service.api.id
}
strip_path = false
preserve_host = true
regex_priority = 0
}
# Attach plugins to route
resource "kong_plugin" "route_jwt" {
route_id = kong_route.api_v1.id
name = "jwt"
config_json = jsonencode({
header_names = ["Authorization"]
})
}
Kong Declarative Config (DB-less mode)
# kong/kong.yaml — for DB-less / decK management
_format_version: "3.0"
_transform: true
services:
- name: api-service
url: http://api-service:3000
routes:
- name: api-v1
paths: [/api/v1]
protocols: [https]
plugins:
- name: jwt
config:
header_names: [Authorization]
claims_to_verify: [exp]
- name: rate-limiting
config:
minute: 1000
policy: redis
redis_host: redis
- name: cors
config:
origins: ["https://app.viprasol.com"]
methods: [GET, POST, PUT, DELETE, OPTIONS]
headers: [Authorization, Content-Type, X-API-Key]
credentials: true
max_age: 3600
AWS API Gateway: JWT Authorizer
For teams using AWS API Gateway (REST or HTTP), the JWT authorizer is the native equivalent:
# infrastructure/api-gateway/auth.tf
resource "aws_apigatewayv2_authorizer" "jwt" {
api_id = aws_apigatewayv2_api.main.id
authorizer_type = "JWT"
identity_sources = ["$request.header.Authorization"]
name = "jwt-authorizer"
jwt_configuration {
audience = ["https://api.viprasol.com"]
issuer = "https://auth.viprasol.com"
# API Gateway automatically fetches JWKS from {issuer}/.well-known/jwks.json
}
}
# Attach to routes
resource "aws_apigatewayv2_route" "protected" {
api_id = aws_apigatewayv2_api.main.id
route_key = "ANY /api/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
authorization_type = "JWT"
authorizer_id = aws_apigatewayv2_authorizer.jwt.id
# Require specific scope for this route
authorization_scopes = ["read:data"]
}
Authentication Decision Matrix
Is the caller a human user?
YES → JWT (short-lived, RS256, 15-min TTL + refresh token)
NO → Continue...
Is the caller a trusted internal service?
YES → mTLS (certificate-based, no rotation burden)
NO → Continue...
Is the caller a third-party developer?
YES → API Key (scoped, expirable, audit-loggable)
NO → Continue...
Is the caller a partner with delegated access?
YES → OAuth2 client credentials flow
Cost Reference
| Auth Layer | Infrastructure Cost | Ops Complexity |
|---|---|---|
| JWT (stateless) | Near zero | Low |
| JWT + Redis blocklist | $15–50/mo (Redis) | Low |
| API keys with Redis cache | $15–50/mo (Redis) | Medium |
| mTLS | cert storage only (~$0) | Medium (cert rotation) |
| Kong OSS self-hosted | EC2/ECS cost (~$80–200/mo) | High |
| Kong Konnect (managed) | $250–2K/mo | Low |
| AWS API Gateway JWT | $3.50/million API calls | Low |
See Also
- API Security Best Practices: Authentication, Authorization, and Input Validation
- Zero Trust Security Architecture
- API Versioning Strategies That Don't Break Clients
- API Rate Limiting: Advanced Patterns with Redis
- API Gateway Patterns: BFF, Aggregation, and Protocol Translation
Working With Viprasol
Building a multi-tenant API that needs enterprise-grade authentication across user-facing, partner, and service-to-service channels? We design and implement API gateway authentication architectures — JWT issuers, API key management systems, mTLS service meshes, and Kong configurations — that give you a single enforcement point without sacrificing developer experience.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need DevOps & Cloud Expertise?
Scale your infrastructure with confidence. AWS, GCP, Azure certified team.
Free consultation • No commitment • Response within 24 hours
Making sense of your data at scale?
Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.