Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

Add email and password sign-in

Add email/password authentication to an existing RELKIT app, migrate the database, read a session, and verify protected access and sign-out.

Add authentication to an existing RELKIT app using the executable examples/auth-drizzle application. The result is a public auth API, a small session operation, and an application route that requires sign-in. This local tutorial uses Bun and SQLite, with no external identity or email provider.

Use the standard @app/* alias. If needed, follow Create an app first. Preserve existing environment fields, service members, routes, and table exports. If your app already has a Drizzle or Better Auth service, extend it rather than adding a second one.

1. Scaffold the auth baseline

The CLI can create the default email/password Better Auth service and /api/auth catch-all route:

bun run relkit add auth --adapter better-auth --database-dialect sqlite

It reuses the single statically discoverable Drizzle service. When none exists, it creates one with the Better Auth tables instead of the generic items table; PostgreSQL and MySQL are also supported. The command adds blank BETTER_AUTH_SECRET and safe BETTER_AUTH_URL examples, but never generates a secret or runs migrations. Complete the reported environment and migration next steps before starting auth.

It normally installs the selected SDKs, declaration peers, Drizzle patch and Better Fetch override, then checks the project. If you use --no-install, finish with bun install and bun run check before continuing. Installing only the RELKIT wrapper does not supply the native SDK or its repairs.

--adapter accepts only better-auth. --database-dialect chooses SQLite (the default), PostgreSQL, or MySQL when creating the database; an existing Drizzle service must be compatible. The default --base-path /api/auth produces src/routes/api/auth/[[...auth]]/route.ts. Use --base-path /session-auth for a different mount.

Auth is a singleton under the detected service root: src/services/auth in grouped apps and src/auth in existing flat apps. --service and --create-service do not select its location. A second auth service or conflicting schema exports are rejected without overwriting them. The scaffold enables email/password only; add other providers and protection rules in source.

For every scaffolding flag, see the CLI reference.

2. Prepare the database and environment

The scaffold above already handles installation. If you are creating these files manually, use Better Auth service as the alternative setup path. Add the environment fields to your existing configuration:

/** Declares auth/database environment requirements without selecting inspection. */
import { defineApp, defineEnv, env } from "@relkit/app/config";

export default defineApp({
  env: defineEnv({
    DATABASE_PATH: env.string().default("./auth.sqlite"),
    BETTER_AUTH_SECRET: env.secret(),
    BETTER_AUTH_URL: env.url().default(new URL("http://127.0.0.1:3000")),
  }),
  server: { port: 3000 },
});

From your application root, choose a fresh local database and supply a local secret:

export DATABASE_PATH=./auth.sqlite
export BETTER_AUTH_URL=http://127.0.0.1:3000
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"

Keep these values for the server commands below. The generated secret starts a fresh local exercise; store a stable secret securely for subsequent runs. Do not commit it or the SQLite file.

Add the authentication tables to the database schema:

/** Declares native Better Auth SQLite user/session/account/verification tables, retaining timestamp modes and account uniqueness. */
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";

const timestamps = {
  createdAt: integer({ mode: "timestamp" }).notNull(),
  updatedAt: integer({ mode: "timestamp" }).notNull(),
};

export const user = sqliteTable("user", {
  id: text().primaryKey(),
  name: text().notNull(),
  email: text().notNull().unique(),
  emailVerified: integer({ mode: "boolean" }).notNull(),
  image: text(),
  ...timestamps,
});

export const session = sqliteTable("session", {
  id: text().primaryKey(),
  expiresAt: integer({ mode: "timestamp" }).notNull(),
  token: text().notNull().unique(),
  ipAddress: text(),
  userAgent: text(),
  userId: text().notNull(),
  ...timestamps,
});

export const account = sqliteTable(
  "account",
  {
    id: text().primaryKey(),
    accountId: text().notNull(),
    issuer: text().notNull(),
    providerId: text().notNull(),
    userId: text().notNull(),
    accessToken: text(),
    refreshToken: text(),
    idToken: text(),
    accessTokenExpiresAt: integer({ mode: "timestamp" }),
    refreshTokenExpiresAt: integer({ mode: "timestamp" }),
    scope: text(),
    password: text(),
    ...timestamps,
  },
  (table) => [uniqueIndex("account_issuer_account_id").on(table.issuer, table.accountId)],
);

export const verification = sqliteTable("verification", {
  id: text().primaryKey(),
  identifier: text().notNull(),
  value: text().notNull(),
  expiresAt: integer({ mode: "timestamp" }).notNull(),
  ...timestamps,
});

Keep the direct user, session, account, and verification exports. These definitions match the example’s installed Better Auth version, including the account issuer field. They are not a drop-in replacement for an existing business users table. Read Database integration before adapting IDs, custom fields, or existing accounts. The example does not declare foreign keys.

Add or extend the database service:

/** Lazily opens the configured SQLite client for native auth tables and closes that owned client on disposal. */
import { Database } from "bun:sqlite";
import { defineDrizzleService } from "@relkit/drizzle";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema/index.js";

export default defineDrizzleService({
  schema,
  client: ({ env }) => {
    const sqlite = new Database(env.DATABASE_PATH);
    return drizzle({ client: sqlite });
  },
  dispose: (database) => database.$client.close(),
});

This service opens and closes the connection. Table creation belongs to migration tooling, not startup.

3. Generate and apply migrations

Add the Drizzle Kit configuration, or merge its schema entry point into your existing configuration:

/** Points native Drizzle tooling at grouped auth tables and requires an explicit persistent SQLite target before migration. */
import { defineConfig } from "drizzle-kit";

const path = process.env.DATABASE_PATH;
if (path === undefined || path.trim() === "" || path === ":memory:") {
  throw new Error("Set DATABASE_PATH to the persistent SQLite file you intend to migrate");
}

export default defineConfig({
  schema: "./src/services/database/schema/index.ts",
  dialect: "sqlite",
  out: "./src/services/database/migrations",
  dbCredentials: { url: path },
});

From your app root, with DATABASE_PATH still set:

bun --bun drizzle-kit generate --name=auth

Review the generated migration before applying it:

bun run relkit db migrate

Commit SQL and required metadata under src/services/database/migrations, shared by auth and business tables, with your schema. The installed command owns the runner; scaffolding does not apply SQL or generate secrets. If running the repository’s existing example, the initial migration is already present: use bun run db:migrate without generating another one. See Migrations for changes to an existing database and deployment sequencing.

4. Add the auth service and mount

Create src/services/auth/service.ts:

/** Configures email/password Better Auth against the sole database, borrowing URL and secret from server configuration. */
import { defineBetterAuthService } from "@relkit/better-auth";

export default defineBetterAuthService({
  baseURL: process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:3000",
  // Better Auth reads BETTER_AUTH_SECRET from the server process environment.
  emailAndPassword: { enabled: true },
});

Mount its raw handler:

/** Mounts native Better Auth once for all methods and protects the declared account, order and MCP paths. */
import { defineRoute } from "@relkit/app/routes";
import auth from "@app/services/auth/service.js";

export const ALL = defineRoute({
  handler: auth.handler,
  auth: { protected: ["/account/*", "/orders/*", "/mcp"] },
});

This creates the auth mount at /api/auth and requires sessions on the listed application paths. /orders/* and /mcp are examples of additional protected paths; they do not create those endpoints. Do not set database or basePath on the service itself.

Run bun run check now to generate auth/database context types before adding the consuming function.

5. Add a session operation and application routes

Create the function in the Account domain:

/** Reads the invocation's native session and returns validated authentication and user identity fields. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";

export default defineFunction({
  input: z.object({}),
  output: z.object({ authenticated: z.boolean(), userId: z.string().optional() }),
  handler: async (_input, context) => {
    const session = await context.auth.getSession();
    return session === null
      ? { authenticated: false }
      : { authenticated: true, userId: session.user.id };
  },
});

Expose it through the owning service:

/** Exposes native session observation as the account boundary used by profile and session routes. */
import { defineService } from "@relkit/app/services";
import session from "./functions/session.function.js";

export default defineService({ functions: { session } });

Add the public optional-session route:

/** Maps session GET requests to account session observation, including the explicit anonymous result. */
import { defineRoute } from "@relkit/app/routes";
import account from "@app/services/account/service.js";

export const GET = defineRoute({ target: account.session });

Add a protected route targeting the same operation:

/** Maps protected profile GET requests to the account service's native session observation. */
import { defineRoute } from "@relkit/app/routes";
import account from "@app/services/account/service.js";

export const GET = defineRoute({ target: account.session });

The operation handles null; HTTP admission makes /account/profile require a session while /session remains public. This example reports identity only. Add business permission checks before exposing real account or other protected data.

6. Check and run the application

Use Auth testing to add the executable test to your app; it already exists in the repository example. Then run from the app root:

bun run check
bun run typecheck
bun test tests/auth.test.ts
bun run dev

The test uses its own migrated temporary database; it does not create accounts in the running server. Use the backend’s actual port if it differs from 3000, and keep BETTER_AUTH_URL consistent.

7. Verify sign-up, protected access, and sign-out

In a second terminal, start a temporary cookie jar and check the signed-out state:

auth_cookies=$(mktemp -t relkit-auth-cookies)
curl -i http://127.0.0.1:3000/account/profile
curl http://127.0.0.1:3000/session

Expect HTTP 401 for the profile and { "authenticated": false } for the public operation. Create an account and save the response cookie:

curl -i -c "$auth_cookies" http://127.0.0.1:3000/api/auth/sign-up/email \
  -H 'content-type: application/json' \
  -H 'origin: http://127.0.0.1:3000' \
  --data '{"name":"Demo","email":"demo@example.com","password":"local-demo-password-123"}'
curl -b "$auth_cookies" http://127.0.0.1:3000/account/profile

Expect sign-up HTTP 200 with a session cookie, then a profile containing authenticated: true and the generated user ID. This tutorial does not require email verification. The account persists across restarts; use a new email for another sign-up or sign in to the existing account.

Sign out and confirm that the same previously issued cookie no longer admits the protected request:

curl -i -b "$auth_cookies" http://127.0.0.1:3000/api/auth/sign-out \
  -H 'content-type: application/json' \
  -H 'origin: http://127.0.0.1:3000' --data '{}'
curl -i -b "$auth_cookies" http://127.0.0.1:3000/account/profile

Expect sign-out HTTP 200, then profile HTTP 401. Sign in again and replace the cookie jar:

curl -i -c "$auth_cookies" http://127.0.0.1:3000/api/auth/sign-in/email \
  -H 'content-type: application/json' \
  -H 'origin: http://127.0.0.1:3000' \
  --data '{"email":"demo@example.com","password":"local-demo-password-123"}'
curl -b "$auth_cookies" http://127.0.0.1:3000/account/profile
rm "$auth_cookies"

Expect an authenticated profile again. The final command removes only this exercise’s temporary cookie jar, not the account or database. These credentials and raw response displays are for an isolated local exercise; do not print real users’ tokens in shared terminals or logs.

For the browser flow, see Clients. The repository example also serves a small page at http://127.0.0.1:3000 with sign-up, sign-in, sign-out, and session buttons. Finish with Customization before deploying: configure delivery, providers, cookies, rate limiting, persistent storage, and the permission rules your product needs.

Reference and source