Authentication

Built-in auth with better-auth — signup, login, session management

Gencow includes built-in authentication powered by better-auth. No external services (Clerk, Auth0) needed.

Auth modes

Gencow supports two client-side auth strategies: token (default) and cookie. They differ in how the browser stores credentials and how protected API calls are authorized.

Client and backend both matter. The frontend picks a strategy to match deployment (cross-origin SPA vs same-origin SSR). For email/password, both modes are always available — the client calls different routes (/api/auth/sign-in vs /api/auth/sign-in/email) and the server accepts either. For OAuth, the backend restricts which completion styles are allowed via oauth.allowedReturnModes in gencow/auth.ts["token"], ["cookie"], or both. A cookie-only backend rejects token-mode OAuth completion; a token-only backend rejects cookie-mode completion and blocks /api/auth/oauth/exchange.

Once a session exists, protected API calls can be authorized with either style of credential:

Credential Typical source Used by
Authorization: Bearer <JWT> Short-lived access token (5 min) minted by Gencow Token-mode clients
Authorization: Bearer <sessionToken> Better Auth session token Token-mode refresh / bearer plugin
Cookie: better-auth.session_token HttpOnly session cookie Cookie-mode clients (and SSR)

Choose the client strategy to match your deployment:

Token mode (kind: "token") Cookie mode (kind: "cookie")
Best for Separate frontend and API origins (e.g. frontend.combackend.gencow.app) Same origin as the Gencow API (e.g. project.gencow.app)
Protected requests Authorization: Bearer <JWT> credentials: "include" (browser sends the HttpOnly cookie)
JS-visible token Yes — short-lived JWT in memory No — session lives in an HttpOnly cookie
Page reload Restores sessionToken from storage, then refreshes JWT via /api/auth/token Browser resends the cookie automatically
SSR Not directly; framework server mode + a frontend BFF bridge can cover cross-host SSR Supported — forward request cookies to Gencow
Default Yes — createAuthClient() without strategy Opt in explicitly
// Token mode (default) — cross-origin SPA
createAuthClient(baseUrl);

// Cookie mode — same-origin / SSR
createAuthClient(baseUrl, { strategy: { kind: "cookie" } });

Token flow

Sign In →  Server returns { sessionToken, token (JWT), user }
           │
           ├── JWT (5min) → stored in memory only
           ├── sessionToken (7d) → stored in localStorage (default)
           └── user → stored in localStorage (optimistic UI)

Page reload → sessionToken from localStorage
           → POST /api/auth/token → new JWT
           → UI shows user immediately (from localStorage cache)

JWT expires → protected queries/mutations refresh first using sessionToken (no user action needed)

Protected HTTP and realtime calls refresh the short-lived JWT before running. Concurrent wake-ups share one in-flight /api/auth/token request. For XSS-sensitive surfaces, keep sessionToken in memory instead of localStorage — a full page reload then requires signing in again:

createAuthClient(baseUrl, {
    strategy: { kind: "token", sessionTokenStorage: "memory" },
});

No cookies required for API calls. Token mode avoids cross-origin cookie issues (e.g. frontend.com calling backend.gencow.app).

Sign In →  POST /api/auth/sign-in/email
           → Server sets better-auth.session_token (HttpOnly cookie)
           → Client stores user in memory for optimistic UI

Protected API → fetch(..., { credentials: "include" })
              → Browser attaches cookie; server validates session

Page reload → Cookie sent automatically; GET /api/auth/me hydrates user

Protected calls run even when useAuth().token is null — the server returns 401 if the cookie is missing or invalid. When migrating from token mode, clear leftover localStorage entries once. For production, set BETTER_AUTH_URL to your HTTPS app origin so Better Auth issues the secure cookie name.

For OAuth, configure allowed completion modes in gencow/auth.ts. Defaults to token-only (["token"]); opt into cookie mode or both explicitly:

// gencow/auth.ts
export default defineAuth({
    oauth: {
        // Token-only OAuth (default) — cross-origin SPAs
        allowedReturnModes: ["token"],

        // Cookie-only OAuth — same-origin / HttpOnly session required
        // allowedReturnModes: ["cookie"],

        // Both — only when the same backend intentionally serves both client styles
        // allowedReturnModes: ["token", "cookie"],
    },
});

Email/password sign-in does not use this setting. The client sends its strategy as an OAuth return preference; the server resolves it against allowedReturnModes and rejects disallowed modes.

Frontend Setup

1. Create Auth Client

See Auth modes to choose token (default) or cookie.

// src/lib/auth.ts
import { createAuthClient } from "@gencow/client";

const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:5456";

// Pass the same backend origin used by queries, mutations, and realtime tickets.
export const auth = createAuthClient(baseUrl);
export const { signIn, signUp, signOut } = auth;

2. Runtime client + GencowProvider

// src/lib/apiClient.ts
import { createGencowClient } from "@gencow/client";
import { api } from "../gencow/api";
import { auth } from "./auth";

const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:5456";

export const apiClient = createGencowClient({ api, baseUrl, auth });
// src/main.tsx
import { GencowProvider } from "@gencow/react";
import { apiClient } from "./lib/apiClient";

function AppWrapper({ children }: { children: React.ReactNode }) {
    return (
        <GencowProvider apiClient={apiClient}>
            {children}
        </GencowProvider>
    );
}

3. Use in Components

import { signIn, signUp, signOut } from "./lib/auth";
import { useAuth } from "@gencow/react";

function AuthComponent() {
    const { user, isAuthenticated } = useAuth();

    if (!isAuthenticated) {
        return <LoginForm />;
    }

    return (
        <div>
            <p>Welcome, {user?.name || user?.email}</p>
            <button onClick={() => signOut()}>Sign Out</button>
        </div>
    );
}

Sign Up

try {
    const user = await signUp("[email protected]", "password123", "John Doe");
    // user = { id, email, name }
    // Session is automatically established
} catch (e) {
    console.error(e.message); // "Email already registered" etc.
}

Sign In

try {
    const user = await signIn("[email protected]", "password123");
    // user = { id, email, name }
    // Token mode: JWT + sessionToken stored client-side
    // Cookie mode: HttpOnly session cookie set by the server
} catch (e) {
    console.error(e.message); // "Invalid credentials" etc.
}

Sign Out

await signOut();
// Token mode: clears JWT, sessionToken, and cached user from memory/storage
// Cookie mode: clears the HttpOnly session cookie server-side

Google SSO

Add the Google SSO starter:

gencow add sso

The command enables Google login in gencow/auth.ts, adds GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET placeholders, and creates React examples under src/auth/. The implementation uses Better Auth's native socialProviders.google provider.

import { GoogleSignInButton } from "./auth/google-sign-in-button";

export function LoginPage() {
    return <GoogleSignInButton />;
}

Route /auth/callback to the generated callback example. Google Cloud Console must use the backend callback URL:

Authorized JavaScript origin:
https://<app>.<domain>

Authorized redirect URI:
https://<app>.<domain>/api/auth/callback/google

For local frontend development, also add the frontend localhost origin and backend callback URI. Keep GOOGLE_CLIENT_SECRET only in backend/app environment variables; never put it in frontend code.

OAuth Callback Handling

OAuth completion is handled by completeSocialSignIn(code?). The same callback page works for both auth strategies:

  • Token mode redirects back with ?code=...; completeSocialSignIn(code) exchanges that code for { user, sessionToken, token }.
  • Cookie mode redirects back with ?oauth=success; completeSocialSignIn(undefined) reads the current user from the HttpOnly cookie and does not expose sessionToken to JavaScript.
// src/auth/callback.tsx
import { useEffect, useState } from "react";
import { auth } from "../lib/auth";

export function AuthCallbackPage() {
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const params = new URLSearchParams(window.location.search);
        const code = params.get("code") ?? undefined;
        const oauthSuccess = params.get("oauth") === "success";
        const oauthError = params.get("error");

        if (oauthError) {
            setError(oauthError);
            return;
        }

        if (!code && !oauthSuccess) {
            setError("Missing OAuth callback result");
            return;
        }

        auth.completeSocialSignIn(code)
            .then(() => {
                window.history.replaceState({}, "", "/");
                window.location.assign("/");
            })
            .catch((e) => {
                setError(e instanceof Error ? e.message : String(e));
            });
    }, []);

    if (error) return <p>{error}</p>;
    return <p>Completing sign in...</p>;
}

Start OAuth from any button with signInSocial():

import { auth } from "../lib/auth";

export function GoogleSignInButton() {
    return (
        <button
            type="button"
            onClick={() => auth.signInSocial("google", {
                callbackURL: `${window.location.origin}/auth/callback`,
            })}
        >
            Continue with Google
        </button>
    );
}

The SDK automatically sends the selected auth strategy to the server as an OAuth return preference. The server still enforces the allowed modes from trusted backend config.

gencow add sso v1 exposes Google only. Kakao, Naver, Apple, GitHub, and Google API scopes such as Drive or Gmail are separate provider/Connections work.

Public Origins

Gencow uses one public-origin policy for API CORS, Better Auth trusted origins, and OAuth callback validation. The hosted app origin and active custom domains are managed automatically. External frontends are declared in code:

/** @type {import("@gencow/core").GencowConfig} */
export default {
    frontendOrigins: ["https://my-frontend.vercel.app"],
};

Origins must be exact bare origins. Wildcards, paths, query strings, hashes, and userinfo are rejected; production external origins must use https://. Local http://localhost:<port> and http://127.0.0.1:<port> are accepted for development.

Use gencow origins list or the Dashboard Public Origins panel to inspect the effective policy. Do not add custom domains to CORS manually; once the domain is active for the app, OAuth callback URLs on that domain are allowed by the managed policy.

Cross-Host SSR Bridge

When the frontend and Gencow backend are on different hosts, the SSR server cannot read the browser's token-mode sessionToken from localStorage. Use a frontend-owned bridge route and a vetted session library such as iron-session:

// src/lib/auth-client.ts
import { createAuthClient } from "@gencow/client";

export const auth = createAuthClient({
    baseURL: process.env.NEXT_PUBLIC_GENCOW_URL,
    authBridge: {
        basePath: "/api/auth",
        oauthCallbackPath: "/auth/callback",
    },
});

Mount the bridge handlers in your frontend app. The session adapter should delegate cookie encryption/sealing to iron-session, Auth.js, or another reviewed session library.

// app/api/auth/[...action]/route.ts
import { createGencowAuthBridgeHandlers } from "@gencow/client";
import { bridgeSessionAdapter } from "@/lib/session";

const handlers = createGencowAuthBridgeHandlers({
    gencowBaseUrl: process.env.NEXT_PUBLIC_GENCOW_URL!,
    session: bridgeSessionAdapter,
});

export async function POST(req: Request) {
    return handlers.handle(req);
}

Bridge sign-in, sign-up, sign-out, OAuth exchange, and JWT refresh go through /api/auth/*. The browser receives only the short-lived JWT; the Better Auth sessionToken stays inside the frontend HttpOnly cookie. Google OAuth still starts on the Gencow backend, but signInSocial() defaults the callback to the bridge frontend route when authBridge.oauthCallbackPath is set.

SSR and Server Actions

Use the bridge cookie from server code, then call Gencow with Bearer sessionToken:

import { createServerGencowClient } from "@gencow/client";
import { getGencowServerAuth, useServerQuery } from "@gencow/client/next";
import { api } from "@/lib/api";
import { getBridgeSession } from "@/lib/session";

const auth = await getGencowServerAuth(getBridgeSession);
if (!auth) redirect("/login");

const profile = await useServerQuery(api.users.profile, undefined, {
    baseUrl: process.env.NEXT_PUBLIC_GENCOW_URL!,
    auth,
});

const serverClient = createServerGencowClient({
    api,
    baseUrl: process.env.NEXT_PUBLIC_GENCOW_URL!,
    auth,
});
await serverClient.call.mutate(api.users.updateDisplayName, { displayName });

For ISR pages, prefetch only public Gencow queries on the server and load personalized UI from client components after hydration. See apps/examples/nextjs-ssr for React SSR, React ISR, TanStack SSR, TanStack ISR, OAuth, and a Server Action mutation.

useAuth Hook

import { useAuth } from "@gencow/react";

const { token, user, isAuthenticated } = useAuth();
Property Type Description
token string | null Short-lived JWT access token (token mode; auto-refreshed). null in cookie mode.
user { id, email, name } | null Current user info
isAuthenticated boolean Whether user is logged in

Backend Auth

Auth Configuration

Customize auth in gencow/auth.ts:

import { defineAuth } from "@gencow/core";

export default defineAuth({
    user: {
        additionalFields: {
            role: { type: "text", default: "user" },
        },
    },
    // Add better-auth plugins/options:
    // betterAuth: (defaults) => ({
    //     ...defaults,
    //     plugins: [
    //         ...((defaults.plugins as unknown[]) ?? []),
    //         // better-auth plugin instances
    //     ],
    // }),
    // Add email verification:
    // emailVerification: {
    //     sendVerificationEmail: async ({ email, url }) => {
    //         // Send email using your preferred provider
    //     },
    // },
});

Custom User Fields

You can extend the auth user table in two ways:

1. additionalFields in gencow/auth.ts — for fields Better Auth should know about (session, JWT, signup defaults). Codegen reflects them in gencow/generated/schema-auth.gen.ts:

// gencow/auth.ts
export default defineAuth({
    user: {
        additionalFields: {
            role: { type: "text", default: "user" },
        },
    },
});

Supported field types: text, boolean, integer, and timestamp.

2. Own gencow/schema-auth.ts directly — for RLS, extra columns on auth tables, or full control. Copy the contents of gencow/generated/schema-auth.gen.ts into schema-auth.ts, edit in place, and remove the re-export. Codegen never overwrites your file; merge upstream changes from .gen.ts when plugins or additionalFields change.

Default scaffold (re-export only):

// gencow/schema-auth.ts
export * from "./generated/schema-auth.gen";

After codegen, generate and apply migrations:

gencow codegen
gencow db:generate
gencow db:push --local

gencow/generated/schema-auth.gen.ts is regenerated on every gencow codegen (when codegen.authSchema is enabled). gencow/schema-auth.ts is yours — re-export the gen file or fork it, but not both.

To disable auth schema codegen entirely:

// gencow.config.js
/** @type {import('@gencow/core').GencowConfig} */
export default {
  codegen: {
    authSchema: false,
  },
};

For security, custom auth fields are not accepted from signup input by default. That means a field like role gets its default value, and your app should update it from trusted server/admin code.

Auth API Endpoints (Reference)

These are handled automatically by createAuthClient() — you don't need to call them directly:

Endpoint Method Description
/api/auth/sign-up POST Sign up → { user, sessionToken, token }
/api/auth/sign-in POST Sign in → { user, sessionToken, token }
/api/auth/token POST Refresh JWT (Bearer sessionToken)
/api/auth/sign-out POST Sign out (Bearer sessionToken)
/api/auth/sign-up/email POST Cookie-mode native sign up
/api/auth/sign-in/email POST Cookie-mode native sign in
/api/auth/sign-in/social/redirect GET Start OAuth/social sign in
/api/auth/oauth/exchange POST Token-mode OAuth code exchange
/api/auth/me GET Cookie-mode safe user lookup → { user }

❌ Common Mistakes

// ❌ Don't create custom auth routes
fetch("/auth/register", { ... })
fetch("/api/users/login", { ... })

// ❌ Don't manage JWT tokens manually
localStorage.setItem("token", jwt)
headers: { Authorization: `Bearer ${myToken}` }

// ❌ Don't hand-roll cookie mode fetches
fetch("/api/auth/sign-in/email", { credentials: "include" })

// ✅ Use createAuthClient() from @gencow/client — it handles everything
import { createAuthClient } from "@gencow/client";

export const auth = createAuthClient(baseUrl, { strategy: { kind: "cookie" } });
export const { signIn, signUp, signOut } = auth;
await signIn("[email protected]", "password123");
// useAuth() inside <GencowProvider apiClient={apiClient}>

Next Steps