DEV Community

Brian Iyoha
Brian Iyoha

Posted on • Originally published at limenauth.dev

Full-stack auth in Go and React: email + password with Limen

When Limen launched, the TypeScript client was still on the roadmap. It's here now: limen-auth, a typed client for your Limen server with first-class adapters for React, Vue, Svelte, and Solid.

This post builds a full-stack auth flow: email/password sign-up and sign-in, a session-aware UI, and email verification.

  • Backend: a Go server running Limen with the credential-password plugin and Postgres
  • Frontend: a Vite + React app using limen-auth

You'll need Go 1.25+, Node 20+, and a Postgres database. If you have Docker, this gets you one:

docker run --name limen-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
Enter fullscreen mode Exit fullscreen mode

Part 1: The Go server

Install

Create a new module and pull in Limen, the SQL adapter, and the credential-password plugin:

mkdir limen-app && cd limen-app
go mod init example.com/limen-app

go get github.com/thecodearcher/limen
go get github.com/thecodearcher/limen/adapters/sql
go get github.com/thecodearcher/limen/plugins/credential-password
go get github.com/lib/pq
Enter fullscreen mode Exit fullscreen mode

Wire up the server

Create main.go:

package main

import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"
    "os"

    _ "github.com/lib/pq"

    "github.com/thecodearcher/limen"
    sqladapter "github.com/thecodearcher/limen/adapters/sql"
    credentialpassword "github.com/thecodearcher/limen/plugins/credential-password"
)

func main() {
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    auth, err := limen.New(&limen.Config{
        BaseURL:  "http://localhost:8080",
        Database: sqladapter.NewPostgreSQL(db),
        CLI:      &limen.CLIConfig{Enabled: true}, // lets the limen CLI generate migrations

        HTTP: limen.NewDefaultHTTPConfig(
            // Allow the React dev server, which runs on a different origin.
            limen.WithHTTPTrustedOrigins([]string{"http://localhost:5173"}),
            // Local dev runs over plain HTTP; re-enable in production.
            limen.WithHTTPCookieSecure(false),
        ),

        Email: limen.NewDefaultEmailConfig(
            limen.WithEmailVerification(
                limen.WithSendEmailVerificationMail(func(email, token string) {
                    // Plug in your email provider here. For now, log the link.
                    log.Printf("verify %s: http://localhost:5173/verify-email?token=%s", email, token)
                }),
            ),
        ),

        Plugins: []limen.Plugin{
            credentialpassword.New(),
        },
    })
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.Handle("/auth/", auth.Handler())

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", cors(mux)))
}

// cors lets the React dev server on :5173 call the API with credentials.
func cors(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Origin") == "http://localhost:5173" {
            w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
            w.Header().Set("Access-Control-Allow-Credentials", "true")
            w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        }
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

That's the auth server. credentialpassword.New() adds the email/password routes and auth.Handler() serves them under /auth, with password hashing, sessions, and the rest handled for you.

Note: The WithHTTP* options and the cors middleware are there only for cross-origin local dev: they let the browser send the session cookie from :5173 to :8080 over plain HTTP. In production you'd tighten the cookie flag and hand CORS to your framework's middleware.

The email callback logs the verification link for local development. In production, send that link through your email provider.

Set the environment before running:

export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
export LIMEN_SECRET=$(openssl rand -hex 16)
Enter fullscreen mode Exit fullscreen mode

Migrations

Limen ships a CLI that generates SQL migrations from your configured schema. Install it, run the server once so it writes the schema snapshot (.limen/schemas.json, add it to .gitignore), then generate and apply:

go install github.com/thecodearcher/limen/cmd/limen@latest

go run .   # once; Ctrl-C after it starts

limen generate migrations -d postgres -c "$DATABASE_URL" -o ./migrations
Enter fullscreen mode Exit fullscreen mode

The CLI generates migration files; applying them is up to your usual tool. With golang-migrate:

migrate -path ./migrations -database "$DATABASE_URL" up
Enter fullscreen mode Exit fullscreen mode

Start the server again with go run . and the backend is done. Verify it end to end with curl:

curl -i http://localhost:8080/auth/signup/credential \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "password": "Correct-horse-1"}'
Enter fullscreen mode Exit fullscreen mode

You get back the new user, a Set-Cookie session, and a verification link in the server logs.

A protected route of your own

Your own endpoints can require a session with auth.GetSession(r), which reads and validates the session from any request. Add this inside main(), after the /auth/ mount, and restart the server:

mux.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {
    session, err := auth.GetSession(r)
    if err != nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(session.User)
})
Enter fullscreen mode Exit fullscreen mode

GetSession works identically in middleware, in Gin handlers via c.Request, or anywhere else an *http.Request exists.

Part 2: The React app

Set up the client

Scaffold a Vite app next to the server and install the SDK:

npm create vite@latest limen-web -- --template react-ts
cd limen-web && npm install limen-auth
Enter fullscreen mode Exit fullscreen mode

Create the client once and share it across the app. Create src/auth-client.ts:

import { createAuthClient } from 'limen-auth/react'
import { credentialPasswordPlugin } from 'limen-auth/plugins'

export const auth = createAuthClient({
  baseURL: 'http://localhost:8080',
  plugins: [credentialPasswordPlugin()],
})
Enter fullscreen mode Exit fullscreen mode

baseURL is the server origin; the SDK's default basePath is /auth, which matches where we mounted the handler. Cookies are the default transport, and the client already sends credentials: 'include'.

Sign up

Create src/SignUpForm.tsx:

import { useState } from 'react'
import { LimenError } from 'limen-auth'
import { auth } from './auth-client'

export function SignUpForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<string | null>(null)

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault()
    setError(null)
    try {
      await auth.signUp.credential({ email, password })
      // Signed in. The session store updates itself.
    } catch (err) {
      setError(err instanceof LimenError ? err.message : 'Something went wrong')
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Create account</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Sign-up establishes a session on the server (auto sign-in is Limen's default), and the SDK writes it into its reactive session store. Any component watching the session re-renders on its own, so the form doesn't need redirect logic.

Sign in

Same shape. Create src/SignInForm.tsx:

import { useState } from 'react'
import { LimenError } from 'limen-auth'
import { auth } from './auth-client'

export function SignInForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<string | null>(null)

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault()
    setError(null)
    try {
      await auth.signIn.credential({ credential: email, password })
    } catch (err) {
      setError(err instanceof LimenError ? err.message : 'Something went wrong')
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let the session drive the UI

auth.useSession() gives React a live view of the current session. It loads the session on mount and updates when the user signs in or out. Replace src/App.tsx:

import { useState } from 'react'
import { auth } from './auth-client'
import { SignInForm } from './SignInForm'
import { SignUpForm } from './SignUpForm'
import { Dashboard } from './Dashboard'
import { VerifyEmail } from './VerifyEmail'

export default function App() {
  const { data, isPending } = auth.useSession()
  const [mode, setMode] = useState<'signin' | 'signup'>('signin')

  // With a router, make this a route instead.
  if (window.location.pathname === '/verify-email') return <VerifyEmail />

  if (isPending) return <p>Loading…</p>
  if (data) return <Dashboard user={data.user} />

  return (
    <main>
      {mode === 'signin' ? <SignInForm /> : <SignUpForm />}
      <button onClick={() => setMode(mode === 'signin' ? 'signup' : 'signin')}>
        {mode === 'signin' ? 'Need an account? Sign up' : 'Have an account? Sign in'}
      </button>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Submitting the sign-in form updates the store, so the dashboard renders with no navigation code. Signing out clears the store, and the forms come back.

The dashboard

The signed-in view shows the user, their verification status, and calls the protected /api/profile route we wrote in Go. Create src/Dashboard.tsx:

import { useState } from 'react'
import { auth } from './auth-client'

type User = { id: string; email: string; emailVerifiedAt: string | null }

export function Dashboard({ user }: { user: User }) {
  const [resend, setResend] = useState<'idle' | 'sent' | 'failed'>('idle')
  const [profile, setProfile] = useState<string | null>(null)

  async function resendVerification() {
    try {
      await auth.requestEmailVerification()
      setResend('sent')
    } catch {
      setResend('failed')
    }
  }

  async function loadProfile() {
    // Your own API routes: plain fetch, cookie included.
    const res = await fetch('http://localhost:8080/api/profile', { credentials: 'include' })
    setProfile(JSON.stringify(await res.json(), null, 2))
  }

  return (
    <main>
      <h1>Signed in as {user.email}</h1>

      {user.emailVerifiedAt ? (
        <p>Email verified ✓</p>
      ) : (
        <p>
          Email not verified.{' '}
          {resend === 'sent' ? (
            'Check your inbox.'
          ) : (
            <button onClick={resendVerification}>Resend verification email</button>
          )}
          {resend === 'failed' && ' Sending failed, try again.'}
        </p>
      )}

      <button onClick={loadProfile}>Load profile from the Go API</button>
      {profile && <pre>{profile}</pre>}
      <button onClick={() => auth.signout()}>Sign out</button>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

auth.signout() ends the session on the server and clears the store, so the useSession() guard in App flips back to the forms automatically. For your own endpoints, a plain fetch with credentials: 'include' carries the session cookie, and auth.GetSession on the Go side validates it.

Email verification

The verification email (for now, the server log line) links to /verify-email?token=... on the frontend. That page hands the token back to the server. Create src/VerifyEmail.tsx:

import { useEffect, useRef, useState } from 'react'
import { auth } from './auth-client'

export function VerifyEmail() {
  const [status, setStatus] = useState<'verifying' | 'verified' | 'error'>('verifying')
  const ran = useRef(false)

  useEffect(() => {
    if (ran.current) return // guard against double-run in React strict mode
    ran.current = true

    const token = new URLSearchParams(window.location.search).get('token')
    if (!token) {
      setStatus('error')
      return
    }
    auth
      .verifyEmail({ token })
      .then(() => setStatus('verified'))
      .catch(() => setStatus('error'))
  }, [])

  if (status === 'verifying') return <p>Verifying…</p>
  if (status === 'error') return <p>This verification link is invalid or has expired.</p>
  return (
    <p>
      Email verified. <a href="/">Continue to the app</a>
    </p>
  )
}
Enter fullscreen mode Exit fullscreen mode

On success the SDK refreshes the session, so emailVerifiedAt is populated when the user returns to the dashboard.

What you built

A complete email/password auth flow: users can sign up, sign in, verify their email, and reach protected routes on both ends, with production-grade password hashing, session handling, and endpoint protection baked in.

Where to go from here

The same setup extends to:

The full client reference lives in the Client SDK docs, and the source for both sides is on GitHub.

Originally published at limenauth.dev.

Top comments (0)