Building Secure Full-Stack Apps: Go Backend, Better-Auth, and JWT Magic


Modern web development often involves a mix of programming languages, allowing developers to leverage the strengths of each for different parts of an application. This is particularly true for full-stack development, where a robust backend needs to seamlessly integrate with a dynamic frontend. For many, Go (Golang) is the go-to choice for building high-performance APIs and services due to its emphasis on safety, efficiency, and ease of implementation. However, when it comes to comprehensive authentication solutions, Go's standard library can feel a bit sparse compared to the rich, opinionated frameworks available in other web-first languages.

This blog post explores a pragmatic approach to building secure and efficient full-stack applications by combining the power of Go for backend services with a cutting-edge TypeScript authentication framework.

The Authentication Conundrum in Go

Go shines in areas like concurrency, networking, and performance, making it excellent for microservices and APIs. Its standard library offers cryptographic primitives like bcrypt for password hashing and x/oauth2 for OAuth, which are foundational for authentication. However, these are just primitives. Building a complete authentication system from scratch, including user management, sessions, multi-factor authentication (MFA), and integrations with external providers, requires significant boilerplate code and a deep understanding of security best practices.

Compare this to web-first languages like Ruby with Ruby on Rails or PHP with Laravel. These frameworks often come with built-in authentication generators or highly integrated packages that can set up a secure auth system with minimal effort. While there are third-party Go packages like Authboss that offer a modular authentication system, they still typically demand a substantial amount of manual integration for database storage and API endpoints. This means developers often have to wire up many components themselves, which can be time-consuming and error-prone.

Enter Better-Auth: A Game-Changer for TypeScript

For frontend development, especially with modern meta-frameworks like Next.js, TypeScript has become a dominant force. This is where Better-Auth comes into play. It's lauded as a comprehensive authentication framework specifically for TypeScript.

Here's why Better-Auth stands out:

  • Ease of Integration: It simplifies the process of adding authentication to your application, working seamlessly with your API layer, frontend, and even your chosen database and its drivers. You don't need to write much of the underlying implementation yourself.
  • Feature-Rich Plugin System: Better-Auth's true power lies in its extensive plugin system. It offers built-in support for features that are often tedious to implement from scratch or come at a significant cost with commercial auth providers:
    • Two-Factor Authentication (2FA)
    • One-Time Passwords (OTP) (e.g., via email)
    • Magic Links
    • Generic OAuth (e.g., Google, GitHub)
    • Passkeys
    • Organizational features (roles, permissions, team management)
    • Payment integrations (e.g., Stripe, Polar.sh) for subscriptions and webhooks.

Crucially, many of these advanced features are available for free with Better-Auth, a stark contrast to platforms like Clerk or Auth0, where such functionalities can cost hundreds of dollars per month.

The TypeScript-Only Dilemma

The primary "catch" with Better-Auth is that it's exclusively built for TypeScript. This presents a dilemma for developers who prefer to use Go for their backend services. Should they switch their entire backend stack to TypeScript to leverage Better-Auth's benefits?

While tempting, especially for unified language stacks, it's not always necessary to abandon your preferred backend language.

Bridging the Gap: Better-Auth with a Go Backend (using JWT)

The good news is that you can still use Go for your backend APIs while leveraging Better-Auth for your authentication needs. The key lies in using JSON Web Tokens (JWTs).

What is a JWT?

A JWT is a compact, URL-safe means of representing claims to be transferred between two parties. It's essentially a self-contained, cryptographically signed JSON object that includes information about the user (e.g., user ID, email, name) and a signature to verify its authenticity. This makes JWTs perfect for authenticating requests across different services, like your frontend and Go backend.

Frontend (Next.js) Setup with Better-Auth's JWT Plugin

  1. Install Better-Auth: Add the package to your Next.js project.
  2. Configure Better-Auth: Set up your auth.ts file, configuring Better-Auth with your database adapter (e.g., Drizzle) and enabling the jwt plugin for client-side use. This generates the necessary database schema for JWTs.
  3. Generate Database Migrations: Use the Better-Auth CLI (bunx @better-auth/cli generate) to generate the jwks table in your database schema, which will store the public keys used for JWT signing.
  4. Obtain Token: On the frontend, you can use Better-Auth's client-side authClient.token() method to obtain the JWT after a user logs in.
  5. Send Token to Go API: When making authenticated requests to your Go API, include the JWT in the Authorization header with a "Bearer" prefix.

Backend (Go) Implementation: Receiving and Verifying JWTs

On the Go backend, you'll need to parse and verify the incoming JWT.

  1. Use a JWT Library: The video recommends lestrrat-go/jwx/v3/jwt for Go, which provides comprehensive functions for working with JWTs and JWKS (JSON Web Key Sets). Install it using go get github.com/lestrrat-go/jwx/v3/jwt.
  2. Parse the Token: Use jwt.ParseRequest(r) to extract the token from the request headers.
  3. Extract User Information: The user ID is typically stored in the JWT's "subject" claim, accessible via token.Subject(). Other user details like email and name can be extracted using token.Get("claimName", &variable).
  4. Crucial Step: JWT Verification: This is vital for security. You must verify that the JWT was signed by your authentication server and hasn't been tampered with.
    • Fetch Public Keys (JWKS): Better-Auth exposes a JWKS endpoint (e.g., /api/auth/jwks) that provides the public keys used to sign the JWTs.
    • Implement Verification: Use jwk.Fetch() in Go to download and parse these public keys. Then, pass the obtained keyset to jwt.ParseRequest() as an option: jwt.WithKeySet(keyset). This ensures that the token's signature is validated against the correct public key.
    • Caching JWKS: In a production environment, cache the JWKS in memory using jwx.NewCache() to avoid fetching it on every request, improving performance.

Optimizing Client-Side Requests (Caching & Security)

While the direct client-to-server approach works, it can be inefficient and pose security risks:

  • Double-Hop: Every authenticated request from the client to the Go API involves two network calls: one to Better-Auth to get the token, and another to the Go API with the token.
  • Local Storage Risk: Storing JWTs directly in localStorage or sessionStorage makes them vulnerable to Cross-Site Scripting (XSS) attacks. If an attacker can inject malicious script, they can steal the JWT.

Recommended Secure Practices:

  • In-Memory Caching: Cache the JWT in memory on the client-side and only re-fetch it from Better-Auth when it expires.
  • HTTP-Only Secure Cookies: Store the JWT in an HTTP-only secure cookie. This prevents client-side JavaScript from accessing the token, mitigating XSS risks. This approach requires a custom API endpoint on your frontend server to set the cookie.

The Preferred Approach: Server-to-Server Authentication (Request Proxying)

For ultimate security and efficiency in modern meta-frameworks like Next.js (especially with React Server Components), server-to-server request proxying is often the preferred method:

  1. Frontend Request to Next.js API Route: The client makes an authenticated request to a Next.js API route (or a React Server Component). The original client cookies (containing session information from Better-Auth) are automatically sent.
  2. Next.js API Route Obtains Token: The Next.js API route/server component uses Better-Auth's server-side API (auth.api.getToken()) to obtain the JWT from the authenticated session.
  3. Proxy Request to Go Backend: The Next.js API route/server component then acts as a proxy, forwarding the request to your Go backend API. It includes the obtained JWT in the Authorization: Bearer ${token} header of this proxied request.

Benefits of Request Proxying:

  • Higher Security: The JWT never directly touches the client's browser-side JavaScript, significantly reducing XSS vulnerability.
  • Avoids CORS Issues: Since all requests to the Go API originate from your Next.js server (same origin), you don't need to explicitly configure Cross-Origin Resource Sharing (CORS) on your Go API server.
  • No Client-Side Token Caching: The server handles all token management, simplifying frontend code and reducing potential security loopholes.
  • Enables React Server Components: This approach allows you to make authenticated data fetches directly within React Server Components, which are rendered on the server.

The main "caveat" with request proxying is the potential for a "double hop" (client -> Next.js server -> Go API). However, if your frontend and backend servers are co-located (e.g., in the same data center or serverless region), this latency is often negligible.

Conclusion

By strategically combining the strengths of different languages and frameworks, you can build powerful and secure full-stack applications. Go provides a robust and performant foundation for your APIs, while a framework like Better-Auth handles the complexities of authentication with elegance and security in TypeScript. Utilizing server-to-server request proxying further enhances security and streamlines development, especially with modern meta-frameworks.

Comments

Popular posts from this blog

Beginner's Guide to SAP: Learning, Comparisons, and Career Paths