---
title: Add email and password sign-in
description: Add email/password authentication to an existing RELKIT app, migrate the database, read a session, and verify protected access and sign-out.
pagination: false
sidebar:
  icon: shield-check
---

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.

> **Before you begin**
>
> You'll need an existing RELKIT application, Bun 1.3.10 or later, and a new local SQLite database
> for this example. Keep any existing database and migration history intact.

Use the standard `@app/*` alias. If needed, follow [Create an app](/docs/learn/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. ## 1. Scaffold the auth baseline [#scaffold-the-auth-baseline]

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

    ```sh
    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](/docs/api/cli).

2. ## 2. Prepare the database and environment [#prepare-the-database-and-environment]

    The scaffold above already handles installation. If you are creating these files manually, use
    [Better Auth service](/docs/modules/auth/better-auth#install-compatible-packages) as the alternative setup path.
    Add the environment fields to your existing configuration:

    ```ts title="relkit.config.ts"
    /** 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:

    ```sh
    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:

    ```ts title="src/services/database/schema/index.ts"
    /** 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](/docs/modules/database/better-auth)
    before adapting IDs, custom fields, or existing accounts. The example does not declare foreign keys.

    Add or extend the database service:

    ```ts title="src/services/database/service.ts"
    /** 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. ## 3. Generate and apply migrations [#generate-and-apply-migrations]

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

    ```ts title="drizzle.config.ts"
    /** 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:

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

    Review the generated migration before applying it:

    ```sh
    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](/docs/modules/database/migrations) for changes to an existing database and deployment sequencing.

4. ## 4. Add the auth service and mount [#add-the-auth-service-and-mount]

    Create `src/services/auth/service.ts`:

    ```ts title="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:

    ```ts title="src/routes/api/auth/[[...auth]]/route.ts"
    /** 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. ## 5. Add a session operation and application routes [#add-a-session-operation-and-application-routes]

    Create the function in the Account domain:

    ```ts title="src/services/account/functions/session.function.ts"
    /** 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:

    ```ts title="src/services/account/service.ts"
    /** 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:

    ```ts title="src/routes/session/route.ts"
    /** 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:

    ```ts title="src/routes/account/profile/route.ts"
    /** 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](/docs/modules/auth/protect-routes#enforce-permissions-in-the-business-operation)
    before exposing real account or other protected data.

6. ## 6. Check and run the application [#check-and-run-the-application]

    Use [Auth testing](/docs/modules/auth/testing) to add the executable test to your app; it already exists in the
    repository example. Then run from the app root:

    ```sh
    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. ## 7. Verify sign-up, protected access, and sign-out [#verify-sign-up-protected-access-and-sign-out]

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

    ```sh
    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:

    ```sh
    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:

    ```sh
    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:

    ```sh
    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](/docs/modules/auth/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](/docs/modules/auth/customization) before deploying: configure delivery, providers, cookies,
    rate limiting, persistent storage, and the permission rules your product needs.

{/* Generated by apps/docs/scripts/generate.ts. Do not edit. */}

**Reference and source**

- [`@relkit/better-auth` API](/docs/api/better-auth)
- [`@relkit/drizzle` API](/docs/api/drizzle)
- [`@relkit/routes` API](/docs/api/routes)

- [`examples/auth-drizzle/README.md`](https://github.com/rel-kit/relkit/blob/main/examples/auth-drizzle/README.md)

## Related tasks

Use the session to [protect application routes](/docs/modules/auth/protect-routes), then try
[Upload product images](/guides/storage/upload-product-images).
