
If you've seen the recent Dreams of Code article, you know the hype is real. Better Auth is arguably the best authentication solution for the Typescript ecosystem right now. It is type-safe, extensible, and incredibly easy to set up.
However, there is a catch: Better Auth is designed to run on a Javascript runtime (Node/Bun/Deno).
In this post, I'll show you how to architect a "Proxy Pattern" using Tanstack Start to handle authentication in Node, while securely passing authorization contexts to a Go Fiber backend.
Since Better Auth cannot run inside Go, we run it inside our Tanstack Start server (which runs on Node/Bun). We then use a JWT Strategy to communicate with the Go backend.
Here is the flow:
/api/externalFirst, we need to enable the JWT plugin. This is crucial because standard session cookies are opaque to the Go backend. We need a token that Go can cryptographically verify.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
advanced: {
cookiePrefix: "your-app", // Custom prefix for cookies
},
baseURL: process.env.VITE_BETTER_AUTH_URL || "http://localhost:3000",
database: drizzleAdapter(db, {
provider: "pg",
}),
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
plugins: [
// The JWT plugin is essential for external backends
jwt(),
],
});On the client side, the setup remains standard:
import { createAuthClient } from "better-auth/react";
import { jwtClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_BETTER_AUTH_URL || "http://localhost:3000",
plugins: [
jwtClient(),
]
})This is the secret sauce. Instead of the browser hitting the Go backend directly (which would require complex CORS and cookie sharing), we create a "Catch-All" route in Tanstack Start (routes/api/external/$.ts).
This route intercepts the request, grabs the valid token using auth.api.getToken, and forwards the request to Go with a standard Authorization: Bearer <token> header.
//routes/api/external/$.ts
import { createServerFn } from "@tanstack/react-start";
import { auth } from "../../../lib/auth";
import { createFileRoute } from "@tanstack/react-router";
import { getRequestHeaders } from "@tanstack/react-start/server";
// 1. Helper to fetch the raw token server-side
const getTokenFn = createServerFn().handler(async () => {
const headers = getRequestHeaders();
const { token } = await auth.api.getToken({
headers: headers,
});
return token;
});
const handleRequest = async (request: Request) => {
const isBodyAllowed = request.method !== "GET" && request.method !== "HEAD";
// 2. Get the JWT intended for the external API
const token = await getTokenFn();
// 3. Strip the proxy prefix so Go receives a clean path
const url = request.url.split("api/external")[1];
// Check for file uploads
const contentType = request.headers.get("content-type") || "";
const isFormData = contentType.includes("multipart/form-data");
let requestBody: BodyInit | undefined;
if (isBodyAllowed) {
if (isFormData) {
requestBody = await request.arrayBuffer();
} else {
const text = await request.text();
requestBody = text || undefined;
}
}
// 4. Attach the Token to the new headers
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (isFormData) {
headers["content-type"] = contentType; // Preserve boundary for files
} else {
const originalContentType = request.headers.get("content-type");
if (originalContentType) headers["content-type"] = originalContentType;
}
// 5. Forward request to Go
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${url}`, {
method: request.method,
headers,
...(isBodyAllowed && requestBody ? { body: requestBody } : {}),
});
// 6. Return Go's response back to the browser
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
};
export const Route = createFileRoute("/api/external/$")({
server: {
handlers: {
GET: async ({ request }) => handleRequest(request),
POST: async ({ request }) => handleRequest(request),
PUT: async ({ request }) => handleRequest(request),
DELETE: async ({ request }) => handleRequest(request),
PATCH: async ({ request }) => handleRequest(request),
},
},
});Now that the Go server is receiving a standard Bearer token, we need a middleware to validate it.
Unlike Node, Go doesn't have direct access to the Better Auth code. However, because Better Auth creates a standard JWK (JSON Web Key), Go can validate the signature cryptographically without hitting the database.
func UserFromRequest() fiber.Handler {
return func(c *fiber.Ctx) error {
// 1. Extract the Bearer Token
authHeader := c.Get("Authorization")
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if tokenStr == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Missing Token")
}
// 2. Fetch JWKS (Public Keys) from Better Auth
// Note: You should implement caching for getJWKS() to avoid
// fetching keys on every request.
keySet, err := getJWKS()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Key error")
}
// 3. Parse and Verify the Token
token, err := jwt.Parse([]byte(tokenStr), jwt.WithKeySet(keySet))
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid Token")
}
// 4. Extract User Claims
userID := token.Subject()
email, _ := token.Get("email")
name, _ := token.Get("name")
if email == nil || name == nil {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid Claims")
}
// 5. Store in Context for the Route Handlers
c.Locals("user", &model.UserJwt{
ID: userID,
Email: email.(string),
Name: name.(string),
})
return c.Next()
}
}// router/router.go
func SetupRoutes(app *fiber.App) {
// Apply middleware to protected groups
orgs := app.Group("/orgs", middleware.UserFromRequest())
orgs.Get("/", func(c *fiber.Ctx) error {
user := c.Locals("user").(*model.UserJwt)
return c.JSON(fiber.Map{
"message": "Hello " + user.Name,
})
})
}Merging a Node-based meta-framework with a Go backend often feels clunky, but this specific setup solves three major headaches:
/api/external), your browser only ever talks to the Tanstack Start server (Same-Origin). You don't need to configure complex CORS rules or worry about SameSite cookie issues between your frontend and backend domains.No architecture is perfect. Here is what you are "paying" for this convenience:
Client -> Tanstack Start (Node) -> Go Fiber -> Database. While the internal network speed between Node and Go is usually negligible (especially in Kubernetes or same-network deployments), it is technically an extra hop compared to a monolith.routes/api/external/$.ts will need to be significantly more robust to handle streaming connections.If you are a solo developer or a small team, this pattern is a productivity multiplier.
It allows you to treat authentication as "solved" by the Typescript ecosystem, while letting you write your high-performance business logic in Go. You don't have to switch languages completely you just have to let them play to their respective strengths.
You don't have to rewrite your entire backend in Node just to use Better Auth. By leveraging the JWT Plugin and Tanstack Start server functions, you can keep your robust Go architecture while enjoying the modern authentication experience that Better Auth provides.
This setup gives you the best of both worlds: