Back to Blog

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.

Viprasol Tech Team
November 6, 2026
14 min read

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

StrategyLatencyRevocationUse Case
Stateless (verify signature only)~0msNo (must wait for expiry)Short-lived tokens (15 min)
Redis cache (store valid JTIs)~1msYes (delete from cache)Medium-lived tokens (1 hour)
Introspection endpoint5–20msYes (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 LayerInfrastructure CostOps Complexity
JWT (stateless)Near zeroLow
JWT + Redis blocklist$15–50/mo (Redis)Low
API keys with Redis cache$15–50/mo (Redis)Medium
mTLScert storage only (~$0)Medium (cert rotation)
Kong OSS self-hostedEC2/ECS cost (~$80–200/mo)High
Kong Konnect (managed)$250–2K/moLow
AWS API Gateway JWT$3.50/million API callsLow

See Also


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.

Talk to our team → | Explore our cloud solutions →

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

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.