Building a Full-Stack, Type-Safe tRPC Application with mTLS Mutual Authentication


For internal systems demanding the highest level of security—such as core infrastructure consoles or financial data dashboards—relying solely on usernames, passwords, or JWTs is insufficient. Once these credentials are compromised, an attacker can gain access from any device. What we need is a stronger form of authentication that binds access rights to specific, trusted devices. This is precisely where mTLS (Mutual TLS) comes into play.

The problem is, implementing mTLS in a typical web application is not straightforward, especially within a modern, end-to-end type-safe stack. Browsers themselves offer very limited programmatic control over client certificates, posing a significant challenge for communication between our Svelte frontend and tRPC backend.

Our goal today is to build a complete, end-to-end type-safe internal application that enforces device authentication via mTLS from the ground up. We will confront browser limitations head-on, design an architecture that is viable in a production environment, and ensure every layer, from the database to the UI, benefits from the type safety tRPC provides.

Step 1: Laying the Certificate Foundation

Certificates are the cornerstone of mTLS. We need a Certificate Authority (CA) to sign both the server and client certificates, forming a chain of trust. In a real-world project, this would be managed by a corporate PKI infrastructure or a tool like Vault. For this demonstration, we’ll use openssl to create this hierarchy manually.

First, generate the CA’s private key and root certificate.

# Generate CA private key
openssl genrsa -out ca.key 4096

# Generate CA root certificate (valid for 10 years)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/C=US/ST=California/L=San Francisco/O=MyOrg/OU=DevOps/CN=MyInternalCA"

Next, use this CA to sign our tRPC server’s certificate.

# 1. Generate server private key
openssl genrsa -out server.key 4096

# 2. Generate Certificate Signing Request (CSR)
# The CN (Common Name) here must be your service's domain name. For local development, use localhost.
openssl req -new -key server.key -out server.csr -subj "/C=US/ST=California/L=San Francisco/O=MyOrg/OU=Backend/CN=localhost"

# 3. Sign the server certificate with our CA (valid for 1 year)
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -sha256

Finally, generate a unique certificate for our client device. Every device that needs access should have its own certificate to allow for individual revocation.

# 1. Generate client private key
openssl genrsa -out client.key 4096

# 2. Generate client CSR
# The CN can be used to identify the client, e.g., a device or user ID.
openssl req -new -key client.key -out client.csr -subj "/C=US/ST=California/L=San Francisco/O=MyOrg/OU=ClientDevice/CN=device-001"

# 3. Sign the client certificate with our CA
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365 -sha256

We now have the core files for our trust chain: ca.crt, server.crt, server.key, client.crt, and client.key. This is the foundation for everything that follows.

Step 2: Building the mTLS-Enforced tRPC Backend

Our backend must not only serve an API but also reject any request that fails to provide a valid client certificate at the TLS handshake level, long before any application code is executed.

We will use Node.js’s built-in https module to create the server and integrate tRPC on top of it.

Project Structure:

/trpc-mtls-backend
|-- certs/
|   |-- ca.crt
|   |-- server.crt
|   |-- server.key
|-- src/
|   |-- index.ts
|   |-- router.ts
|-- package.json
|-- tsconfig.json

First, install dependencies:
npm install @trpc/server express cors fs
npm install -D typescript ts-node-dev @types/express @types/cors @types/node

src/router.ts - Defining the tRPC Router

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

// Mock a protected data source
const protectedData = {
  id: 'proj_1a2b3c',
  name: 'Project Phoenix',
  status: 'Active',
  budget: 1500000,
  lastUpdated: new Date().toISOString(),
};

export const appRouter = t.router({
  // A public health check endpoint, which wouldn't need mTLS in some setups.
  // However, under our strict configuration, it will still be blocked by mTLS.
  healthcheck: t.procedure.query(() => {
    return { status: 'ok' };
  }),

  // The core protected procedure
  getProjectDetails: t.procedure
    .input(z.object({ projectId: z.string().startsWith('proj_') }))
    .query(({ input }) => {
      // In a real project, this would query a database.
      // This logic only executes after a successful mTLS handshake.
      console.log(`[tRPC] Received request for project: ${input.projectId}`);
      if (input.projectId === protectedData.id) {
        return protectedData;
      }
      return null;
    }),
});

export type AppRouter = typeof appRouter;

src/index.ts - Creating and Configuring the mTLS Server

This is the most critical part of the backend. We use the https module to create a server and pass in our certificate configuration.

import https from 'https';
import fs from 'fs';
import path from 'path';
import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './router';

const PORT = 4000;

const app = express();
app.use(cors()); // In production, configure a stricter CORS policy

// Create tRPC Express middleware
const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => {
  // Client certificate information can be accessed via req.socket.getPeerCertificate()
  // This is extremely useful for fine-grained authorization at the application layer.
  const clientCert = (req.socket as any).getPeerCertificate();
  console.log('Client certificate subject:', clientCert.subject?.CN);
  return { clientCert };
};

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

// --- mTLS Server Configuration ---
// This configuration is the core of our security model, enforcing mTLS.
const httpsOptions = {
  // Server certificate and private key
  key: fs.readFileSync(path.join(__dirname, '..', 'certs', 'server.key')),
  cert: fs.readFileSync(path.join(__dirname, '..', 'certs', 'server.crt')),
  
  // The CA used to verify client certificates
  ca: fs.readFileSync(path.join(__dirname, '..', 'certs', 'ca.crt')),
  
  // requestCert: true - The server must request a client certificate.
  requestCert: true,
  
  // rejectUnauthorized: true - If the client does not provide a valid certificate
  // signed by our CA, the TLS handshake will fail immediately. The connection is
  // terminated before the request ever reaches Express or tRPC.
  // This is a crucial security setting.
  rejectUnauthorized: true, 
};

const server = https.createServer(httpsOptions, app);

server.listen(PORT, () => {
  console.log(`[Server] Secure tRPC server listening on https://localhost:${PORT}`);
});

// Error handling, e.g., to catch TLS handshake errors
server.on('tlsClientError', (err, tlsSocket) => {
    console.error(`[Server] TLS Client Error: ${err.message}`);
    // Unauthorized connection attempts can be observed here.
    const remoteAddress = `${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`;
    console.error(`[Server] Error from: ${remoteAddress}`);
});

Now, start the backend service with ts-node-dev src/index.ts. If you try to access https://localhost:4000/trpc/healthcheck with a browser or curl, the request will fail instantly. curl will report “alert certificate required,” and the browser will show a TLS error. This confirms our mTLS shield is active.

Step 3: The Svelte Frontend and the Local Proxy

We now face our biggest challenge: how does a Svelte application running in a browser present a client certificate to the backend? The short answer is: it can’t, not directly. The browser’s security model prevents JavaScript code from directly accessing and managing certificate files on the local filesystem.

In corporate environments, there are typically two solutions:

  1. OS/Hardware Certificate Store: The client certificate is installed into the operating system’s certificate store or a hardware token. The browser then automatically uses it when making requests. This places a high configuration burden on the user and is difficult to manage at scale.
  2. Local Proxy: Our Svelte application sends all API requests to a locally running HTTP proxy that does not require mTLS. This proxy holds the client certificate and securely forwards the requests to the real backend service using mTLS.

We will adopt the second approach because it’s transparent to the frontend code and is easier to deploy and manage via scripts.

Creating the Local mTLS Proxy

The proxy is a simple Node.js script that uses http-proxy-middleware for forwarding and a custom https.Agent to attach the client certificate.

// local-mtls-proxy.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const https = require('https');
const fs = require('fs');
const path = require('path');

const PROXY_PORT = 3001; // Svelte app will request this port
const TARGET_URL = 'https://localhost:4000'; // The real tRPC backend

const app = express();

// Configure an HTTPS Agent to carry the client certificate
const mTLSAgent = new https.Agent({
    key: fs.readFileSync(path.join(__dirname, 'certs', 'client.key')),
    cert: fs.readFileSync(path.join(__dirname, 'certs', 'client.crt')),
    // This CA is used to verify the server's certificate, preventing man-in-the-middle attacks
    ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca.crt')),
    rejectUnauthorized: true, // Ensure the server certificate is trusted
});

const proxyOptions = {
    target: TARGET_URL,
    changeOrigin: true, // Necessary to set the Host header correctly
    secure: true, // We are proxying to an HTTPS target
    agent: mTLSAgent, // Use our custom mTLS agent
    logLevel: 'debug',
    onError: (err, req, res) => {
        console.error('Proxy Error:', err);
        res.writeHead(500, {
            'Content-Type': 'text/plain',
        });
        res.end('Something went wrong. And we are reporting a custom error message.');
    },
};

app.use('/', createProxyMiddleware(proxyOptions));

app.listen(PROXY_PORT, () => {
    console.log(`[Proxy] Local mTLS proxy running on http://localhost:${PROXY_PORT}`);
    console.log(`[Proxy] Forwarding requests to ${TARGET_URL}`);
});

Before running the Svelte app, copy the client certificate (client.key, client.crt) and the CA certificate (ca.crt) to a directory accessible by the proxy script, then start it: node local-mtls-proxy.js.

Building the Svelte Frontend

Now we can build our SvelteKit app as usual. The only difference is that the tRPC client will point to the local proxy at http://localhost:3001.

Project Structure:

/svelte-mtls-ui
|-- src/
|   |-- lib/
|   |   |-- trpc.ts
|   |-- routes/
|       |-- +page.svelte
...

Install dependencies:
npm install @trpc/client @tanstack/svelte-query
npx svelte-add@latest shadcn-ui (and select components like Button, Card as needed)

src/lib/trpc.ts - Configuring the tRPC Client

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../../trpc-mtls-backend/src/router'; // Key: Import types directly from the backend
import { QueryClient } from '@tanstack/svelte-query';

export const queryClient = new QueryClient();

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      // Requests are sent to the local proxy, not the mTLS backend directly
      url: 'http://localhost:3001/trpc', 
    }),
  ],
});

The type import, import type { AppRouter } from ..., is tRPC’s superpower. It creates a type-safe contract between the backend and frontend. If the appRouter definition changes in the backend, TypeScript will immediately flag errors in the frontend code.

src/routes/+page.svelte - Building the UI and Calling the API

We’ll use Shadcn UI to quickly build a clean interface and @tanstack/svelte-query to manage the data fetching state.

<script lang="ts">
  import { onMount } from 'svelte';
  import { trpc } from '$lib/trpc';
  import { createQuery } from '@tanstack/svelte-query';

  import { Button } from '$lib/components/ui/button';
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
  import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert';
  import { Terminal } from 'lucide-svelte';

  const projectId = 'proj_1a2b3c';

  // Use svelte-query to wrap the tRPC call
  const projectDetailsQuery = createQuery({
    queryKey: ['projectDetails', projectId],
    queryFn: () => trpc.getProjectDetails.query({ projectId }),
    enabled: false, // Don't execute on mount
  });

  function fetchData() {
    // Manually trigger the query
    $projectDetailsQuery.refetch();
  }
</script>

<div class="container mx-auto p-8 max-w-2xl">
  <Card>
    <CardHeader>
      <CardTitle>Secure Internal Dashboard</CardTitle>
      <CardDescription>
        This application communicates with a backend service protected by mandatory mTLS.
      </CardDescription>
    </CardHeader>
    <CardContent class="space-y-4">
      <Button on:click={fetchData} disabled={$projectDetailsQuery.isFetching}>
        {#if $projectDetailsQuery.isFetching}
          Fetching...
        {:else}
          Fetch Project Details
        {/if}
      </Button>

      {#if $projectDetailsQuery.isError}
        <Alert variant="destructive">
          <Terminal class="h-4 w-4" />
          <AlertTitle>Error Fetching Data</AlertTitle>
          <AlertDescription>
            Failed to connect to the backend. Is the local mTLS proxy running?
            <pre class="mt-2 text-xs bg-gray-800 p-2 rounded">{$projectDetailsQuery.error.message}</pre>
          </AlertDescription>
        </Alert>
      {/if}

      {#if $projectDetailsQuery.isSuccess && $projectDetailsQuery.data}
        {@const data = $projectDetailsQuery.data}
        <Card class="bg-secondary">
            <CardHeader>
                <CardTitle>{data.name}</CardTitle>
                <CardDescription>ID: {data.id}</CardDescription>
            </Header>
            <CardContent>
                <p><strong>Status:</strong> {data.status}</p>
                <p><strong>Budget:</strong> ${data.budget.toLocaleString()}</p>
                <p><strong>Last Updated:</strong> {new Date(data.lastUpdated).toLocaleString()}</p>
            </CardContent>
        </Card>
      {/if}
    </CardContent>
  </Card>
</div>

Now, ensure the backend service and the local proxy are running, then start the SvelteKit app. Click the “Fetch Project Details” button, and the data will load successfully. If you stop the local proxy or attempt to start it with an invalid client certificate, the frontend will immediately display an error. This validates our end-to-end secure channel.

Architecture Review and Trade-offs

We’ve successfully built a complete system. Let’s visualize the data flow and security boundaries with a diagram.

graph TD
    subgraph Client Machine
        A[Browser: Svelte/Shadcn UI] --> B{Local mTLS Proxy};
    end

    subgraph Secure Network
        C[tRPC Backend on Node.js];
    end
    
    B -- "HTTPS with Client Cert (mTLS)" --> C;
    A -- "Plain HTTP (localhost)" --> B;

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:2px

The advantages of this architecture are clear:

  1. Strong Device Authentication: Only devices holding a valid client certificate can establish a connection with the backend.
  2. End-to-End Type Safety: tRPC guarantees the data contract between the frontend and backend, catching any mismatches at compile time.
  3. Frontend Decoupling: The Svelte application itself is oblivious to the complexities of mTLS; it simply communicates with a standard local HTTP service.

Limitations and Future Iteration Paths

While robust, this solution has several considerations for a production deployment.

First, certificate distribution and rotation is the biggest challenge. Manually generating and distributing certificates is not scalable. A mature system requires an automated Public Key Infrastructure (PKI), for instance, using HashiCorp Vault to dynamically generate short-lived certificates and deploying them securely to client machines via device management tools like JAMF or Intune.

Second, deployment and lifecycle management of the local proxy. You need to ensure the proxy is correctly installed, started, and kept running on employee devices. This often involves writing installation scripts and integrating them into the company’s device management workflow. It can be a potential pain point for non-technical users.

Finally, this only solves for authentication, not authorization. We know that device-001 is making the request, but we don’t know which user is on that device. Typically, mTLS is combined with another authentication layer (like an OIDC login flow). mTLS ensures the device is trusted, while OIDC ensures the user is trusted. The CN extracted from the client certificate (device-001) can be associated with a user identity on the backend to implement more granular access control.


  TOC