Authentication Setup

Vinoflare templates use Better Auth for authentication, with Discord OAuth pre-configured. This guide covers setup, customization, and best practices.

Initial Setup

1. Create Discord Application

  1. Go to Discord Developer Portal
  2. Click "New Application"
  3. Name your application
  4. Go to OAuth2 → General
  5. Add redirect URLs:
    • Development: http://localhost:8787/api/auth/callback/discord
    • Production: https://yourdomain.com/api/auth/callback/discord

2. Get Credentials

In your Discord app settings:

  • Copy Client ID
  • Copy Client Secret (under "Reset Secret")

3. Configure Environment

# .dev.vars
BETTER_AUTH_SECRET=your-secret-key-min-32-chars
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_CLIENT_SECRET=your-discord-client-secret

Generate a secure secret:

openssl rand -base64 32

Better Auth Configuration

Server Setup

// src/server/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { discord } from "better-auth/social-providers";
import { db } from "@/server/db";
 
export function createAuth(env: Env) {
  return betterAuth({
    database: drizzleAdapter(db, {
      provider: "sqlite",
    }),
    secret: env.BETTER_AUTH_SECRET,
    socialProviders: {
      discord: {
        clientId: env.DISCORD_CLIENT_ID,
        clientSecret: env.DISCORD_CLIENT_SECRET,
      },
    },
    trustedOrigins: [
      "http://localhost:5173",
      "http://localhost:8787",
      env.FRONTEND_URL, // Production URL
    ],
  });
}

Database Schema

Better Auth automatically creates these tables:

  • users - User accounts
  • sessions - Active sessions
  • accounts - OAuth provider accounts
  • verification_tokens - Email verification

You can extend the user table:

// src/server/db/schema.ts
export const users = sqliteTable("users", {
  id: text("id").primaryKey(),
  email: text("email").notNull().unique(),
  name: text("name"),
  image: text("image"),
  // Add custom fields
  role: text("role").default("user"),
  bio: text("bio"),
  createdAt: integer("created_at", { mode: "timestamp" }),
});

Frontend Integration

Auth Client

// src/client/lib/auth.ts
import { createAuthClient } from "better-auth/react";
 
export const authClient = createAuthClient({
  baseURL: import.meta.env.DEV 
    ? "http://localhost:8787" 
    : window.location.origin,
});
 
export const {
  signIn,
  signOut,
  useSession,
} = authClient;

Login Component

// src/client/components/login-button.tsx
import { signIn } from "@/client/lib/auth";
 
export function LoginButton() {
  const handleLogin = async () => {
    await signIn.social({
      provider: "discord",
      callbackURL: "/dashboard",
    });
  };
 
  return (
    <button
      onClick={handleLogin}
      className="flex items-center gap-2 px-4 py-2 bg-[#5865F2] text-white rounded-lg hover:bg-[#4752C4] transition-colors"
    >
      <DiscordIcon className="w-5 h-5" />
      Sign in with Discord
    </button>
  );
}

Protected Routes

// src/client/components/protected-route.tsx
import { useSession } from "@/client/lib/auth";
import { Navigate } from "@tanstack/react-router";
 
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { data: session, isPending } = useSession();
 
  if (isPending) {
    return <div>Loading...</div>;
  }
 
  if (!session) {
    return <Navigate to="/login" />;
  }
 
  return <>{children}</>;
}

Use in routes:

// src/client/routes/dashboard.tsx
export const Route = createFileRoute("/dashboard")({
  component: () => (
    <ProtectedRoute>
      <DashboardPage />
    </ProtectedRoute>
  ),
});

Backend Protection

Auth Middleware

// src/server/middleware/auth-guard.ts
import type { Context, Next } from "hono";
import { createAuth } from "@/server/lib/auth";
 
export function authGuard() {
  return async (c: Context, next: Next) => {
    const auth = createAuth(c.env);
    const session = await auth.api.getSession({
      headers: c.req.raw.headers,
    });
 
    if (!session) {
      return c.json({ error: "Unauthorized" }, 401);
    }
 
    c.set("user", session.user);
    c.set("session", session);
    
    await next();
  };
}

Protect API Routes

// src/server/modules/posts/posts.routes.ts
import { authGuard } from "@/server/middleware/auth-guard";
 
export const postsRouter = createRouter()
  // Public routes
  .get("/", handlers.getPosts)
  .get("/:id", handlers.getPost)
  
  // Protected routes
  .use("*", authGuard())
  .post("/", handlers.createPost)
  .put("/:id", handlers.updatePost)
  .delete("/:id", handlers.deletePost);

Access User in Handlers

// src/server/modules/posts/posts.handlers.ts
export async function createPost(c: Context) {
  const user = c.get("user");
  const data = c.req.valid("json");
  
  const post = await db.insert(posts).values({
    ...data,
    authorId: user.id,
    authorName: user.name,
  }).returning();
  
  return c.json(post[0], 201);
}

Session Management

Get Current User

// Frontend
const { data: session } = useSession();
const user = session?.user;
 
// Backend
const user = c.get("user");

Sign Out

// Frontend
import { signOut } from "@/client/lib/auth";
 
function LogoutButton() {
  const handleLogout = async () => {
    await signOut();
    window.location.href = "/";
  };
 
  return <button onClick={handleLogout}>Sign Out</button>;
}

Session Refresh

Better Auth automatically refreshes sessions. Configure the behavior:

export const auth = betterAuth({
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // Update after 1 day
  },
});

Adding More Providers

GitHub OAuth

import { github } from "better-auth/social-providers";
 
export const auth = betterAuth({
  socialProviders: {
    discord: {
      clientId: env.DISCORD_CLIENT_ID,
      clientSecret: env.DISCORD_CLIENT_SECRET,
    },
    github: {
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    },
  },
});

Google OAuth

import { google } from "better-auth/social-providers";
 
export const auth = betterAuth({
  socialProviders: {
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },
});

Email/Password Auth

Enable Email Auth

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
  },
  // Email provider for sending verification emails
  email: {
    from: "noreply@yourdomain.com",
    sendEmail: async ({ to, subject, html }) => {
      // Use your email service (Resend, SendGrid, etc.)
      await resend.emails.send({
        from: "noreply@yourdomain.com",
        to,
        subject,
        html,
      });
    },
  },
});

Sign Up Form

import { authClient } from "@/client/lib/auth";
 
function SignUpForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    
    const { error } = await authClient.signUp.email({
      email,
      password,
    });
    
    if (error) {
      toast.error(error.message);
    } else {
      toast.success("Check your email for verification");
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
    </form>
  );
}

Role-Based Access Control

Define Roles

// src/server/lib/permissions.ts
export const ROLES = {
  USER: "user",
  ADMIN: "admin",
  MODERATOR: "moderator",
} as const;
 
export type Role = typeof ROLES[keyof typeof ROLES];
 
export const PERMISSIONS = {
  [ROLES.USER]: ["read:own", "write:own"],
  [ROLES.MODERATOR]: ["read:all", "write:own", "moderate:content"],
  [ROLES.ADMIN]: ["read:all", "write:all", "admin:all"],
};

Role Middleware

export function requireRole(roles: Role[]) {
  return async (c: Context, next: Next) => {
    const user = c.get("user");
    
    if (!user || !roles.includes(user.role)) {
      return c.json({ error: "Forbidden" }, 403);
    }
    
    await next();
  };
}
 
// Usage
adminRouter
  .use("*", authGuard())
  .use("*", requireRole([ROLES.ADMIN]))
  .get("/users", handlers.getAllUsers);

Frontend Role Check

function useRole() {
  const { data: session } = useSession();
  
  return {
    isAdmin: session?.user?.role === ROLES.ADMIN,
    isModerator: [ROLES.ADMIN, ROLES.MODERATOR].includes(session?.user?.role),
    hasRole: (role: Role) => session?.user?.role === role,
  };
}
 
// Usage
function AdminPanel() {
  const { isAdmin } = useRole();
  
  if (!isAdmin) {
    return <div>Access denied</div>;
  }
  
  return <div>Admin content</div>;
}

Security Best Practices

1. Secure Cookies

export const auth = betterAuth({
  cookie: {
    secure: true, // HTTPS only in production
    httpOnly: true, // Not accessible via JavaScript
    sameSite: "lax", // CSRF protection
  },
});

2. CORS Configuration

app.use("*", cors({
  origin: (origin) => {
    const allowed = [
      "http://localhost:5173",
      "https://yourdomain.com",
    ];
    return allowed.includes(origin) ? origin : null;
  },
  credentials: true,
}));

3. Rate Limiting

import { rateLimiter } from "hono-rate-limiter";
 
app.use("/api/auth/*", rateLimiter({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 5, // 5 attempts
  keyGenerator: (c) => c.req.header("CF-Connecting-IP") || "unknown",
}));

4. Input Validation

const signUpSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
});

Troubleshooting

Common Issues

"Unauthorized" errors

  • Check trustedOrigins includes your URL
  • Verify cookies are being sent
  • Check CORS configuration

Session not persisting

  • Ensure credentials: "include" in fetch
  • Check cookie settings
  • Verify domain matches

OAuth redirect fails

  • Update redirect URLs in provider settings
  • Check environment variables
  • Verify callback URL format

Debug Mode

export const auth = betterAuth({
  logger: {
    level: "debug",
  },
});

Next Steps