Skip to main content
20% off 3 months · code LAUNCH20 · until 31 October

Chapter 4 of 9 · For everyone

How do you add a database and login to an app built with AI?

23 September 2026 · by Nordva, who make Nordva Launch

On this pageIf you use an AI builder
  1. If you use an AI builder
  2. If you use a coding agent
  3. Back to the top

The short answer

On an AI builder, ask it to add its own database and sign-in, which most of them set up for you. With a coding agent, create a Cloudflare D1 database, bind it to your Worker, add tables through migrations, and let a login library such as Better Auth store users and sessions in that same database. Then check who is signed in on every request that reads their data.

Almost every app needs two things sooner or later: somewhere to keep data (the notes, bookings or orders people create) and a way to know who is who (login). The two belong together, because the whole point of login is that each person sees only their own data.

If you use an AI builder

AI builders ship their own database and sign-in, and the simplest path is to use what they give you. As their docs describe it on 23 September 2026:

  • Lovable: Lovable Cloud provides “a database, real-time updates, user authentication, and storage”, built on Supabase, with monthly Cloud grants on its Free, Pro and Business plans.
  • Bolt: Bolt creates a database automatically when your project needs one, and lets you pick Supabase instead when you start a project. Sign-up and login may not be added on their own, so ask for them; its authentication settings then cover email confirmation, password reset and Google sign-in.
  • Replit: a fully managed SQL database (PostgreSQL) that its Agent sets up, plus two sign-in options: Replit Auth (people sign in with a Replit account) or Clerk Auth (people create accounts in your app).

Ask the builder in plain words: “Add user accounts with email and password. Each user should only see their own data.” Then test it with two accounts in two browsers: sign in as one, create something, and check the other cannot see it. If you have not chosen between a builder and a coding agent yet, read Pick your tools. The rest of this chapter is for the coding-agent path.

If you use a coding agent

This guide keeps everything on Cloudflare: your app runs as a Worker, your data lives in D1 (Cloudflare’s SQL database, which speaks SQLite), and login comes from a small library that stores its users in that same D1 database. Everything below was checked against the vendors’ current docs on 23 September 2026, not run end to end, so if a command prints something different, trust the docs linked at the bottom.

Why Better Auth for login

We chose Better Auth because its own docs cover this exact setup: a Cloudflare Workers handler, the one compatibility flag Workers needs, and a Cloudflare D1 example where you pass your D1 binding straight in as the database. It has email and password built in, hashes passwords with scrypt for you, keeps sessions in your database, and sets the session cookie as httpOnly and SameSite=Lax, with Secure when your site is on https. Your users stay in your own database, so there is no second service to sign up for and no second bill. At the time of writing its docs are for the 1.7 release line.

Free-tier limits for D1

On the Workers Free plan, Cloudflare’s pricing and limits pages said this on 23 September 2026:

  • 5 million rows read a day and 100,000 rows written a day, reset at 00:00 UTC. Past that, queries return errors until the reset.
  • 5 GB of storage in total, up to 10 databases, each at most 500 MB.
  • 50 queries per Worker invocation.

For a first version with a few hundred users that is plenty, but check the pages again: free tiers change.

Step 1: create the database and bind it

From your project folder:

npx wrangler d1 create my-app-db

Wrangler prints a binding block with the database’s id and asks whether it should add it to your Wrangler config for you. Either way, check the result against the block below. A binding is the name your code uses to reach the database; this guide uses DB, so your code calls env.DB. If Wrangler picked another name (it bases it on the database name), change it to DB. Better Auth also needs the nodejs_compat flag, and its docs pair that flag with a compatibility_date of 2024-09-23 or later. Add these keys to the config you already have, keeping its name, main and compatibility_date. In wrangler.jsonc:

{
  "compatibility_flags": ["nodejs_compat"],
  "vars": {
    "BETTER_AUTH_URL": "http://localhost:8787"
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-app-db",
      "database_id": "<unique-ID-for-your-database>"
    }
  ]
}

Or, if your project uses wrangler.toml:

compatibility_flags = ["nodejs_compat"]

[vars]
BETTER_AUTH_URL = "http://localhost:8787"

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "<unique-ID-for-your-database>"

Change BETTER_AUTH_URL to your real address when you deploy (see Host it for free).

Step 2: your first table, as a migration

A migration is a numbered SQL file that changes the database’s shape. You keep them in git, so every change is written down and can be applied in the same order everywhere.

npx wrangler d1 migrations create my-app-db create_notes

This makes a numbered .sql file in a migrations/ folder. Put your table in it. This example keeps notes, each belonging to one user:

CREATE TABLE notes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  body TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX notes_user_id ON notes (user_id);

Apply it to the local copy first, then to the real database:

npx wrangler d1 migrations apply my-app-db --local
npx wrangler d1 migrations apply my-app-db --remote

Wrangler asks you to confirm before it applies anything. wrangler dev uses the local copy by default, so you can break things freely while you build. D1 records which migrations it has applied in a table called d1_migrations, so running apply again only runs the new ones.

Better Auth needs four tables of its own: user, session, account and verification. Its database docs list their columns under “Core schema”. Its command-line tool cannot reach D1 (D1 is only reachable through a Worker), so the plainest route is a second migration:

npx wrangler d1 migrations create my-app-db better_auth

Put in it the SQL below. We did not type it: it is what Better Auth 1.7.5’s own migration helper produced for SQLite with email and password switched on. If you add Better Auth plugins later, check their docs for extra tables or columns.

create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null);

create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade);

create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null);

create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date not null, "updatedAt" date not null);

create index "session_userId_idx" on "session" ("userId");
create index "account_userId_idx" on "account" ("userId");
create index "verification_identifier_idx" on "verification" ("identifier");

Apply it with the same two apply commands. The docs also show a programmatic route through a /migrate endpoint; if you use it, protect or remove that endpoint before you deploy, as the docs warn.

Step 3: query from the Worker, with bind()

Every query goes through prepare() with ? placeholders, and values go in through bind(). Cloudflare’s docs say binding parameters this way prevents SQL injection. Never build SQL by gluing text together, such as "... WHERE user_id = '" + id + "'": that is how someone else’s input becomes part of your query.

const { results } = await env.DB.prepare(
  "SELECT id, body, created_at FROM notes WHERE user_id = ?"
)
  .bind(userId)
  .run();

Step 4: add Better Auth with email and password

Install it:

npm install better-auth

Create src/auth.ts. This follows the Cloudflare D1 example in Better Auth’s database docs, with email and password switched on:

import { env } from "cloudflare:workers";
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: env.DB,
  secret: env.BETTER_AUTH_SECRET,
  baseURL: env.BETTER_AUTH_URL,
  emailAndPassword: {
    enabled: true,
  },
});

Then send every request under /api/auth to Better Auth, as its installation page shows for Cloudflare Workers. That gives you endpoints such as POST /api/auth/sign-up/email and sign-in, which your pages call through Better Auth’s client library.

Magic links (sign in by clicking a link in an email, no password) are a Better Auth plugin. It needs a sendMagicLink function that actually sends the email, so you need an email provider first. Start with email and password and add magic links later if you want them.

Step 5: sessions, and checking the user on every request

When someone signs in, Better Auth creates a session row in your database and sets a cookie in their browser. By default a session lasts 7 days, and when it is used again after a day its expiry moves forward. Because the cookie is httpOnly, scripts on the page cannot read it, and the browser sends it back with each request.

On the server, ask Better Auth who is signed in by passing the request’s headers, then use that user’s id in the query. Never take the user id from the request body or the URL: anyone can type a different one.

const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
  return new Response("Not signed in", { status: 401 });
}

const { results } = await env.DB.prepare(
  "SELECT id, body, created_at FROM notes WHERE user_id = ?"
)
  .bind(session.user.id)
  .run();

Do this on every route that reads or changes someone’s data, including updates and deletes (WHERE id = ? AND user_id = ?).

Step 6: the secret

Better Auth needs a secret of at least 32 characters to sign and encrypt things. Its docs suggest generating one with:

openssl rand -base64 32

For local development, put it in a .dev.vars file next to your Wrangler config, and add .dev.vars* and .env* to .gitignore:

BETTER_AUTH_SECRET="paste-the-generated-value-here"

For the deployed Worker, store it as a secret. Wrangler asks you to paste the value:

npx wrangler secret put BETTER_AUTH_SECRET

Cloudflare’s docs say not to put sensitive values in vars. The BETTER_AUTH_URL in vars is fine because it is not secret.

The prompt to give your agent

Paste this into Claude Code, Cursor or whichever agent you use, and let it work in small steps you can check:

Add a database and email-and-password login to this Cloudflare Worker.

1. Use Cloudflare D1 with the binding name DB. Tell me the wrangler d1 create
   command to run and wait for me to paste the output before editing the
   Wrangler config. Add the nodejs_compat compatibility flag.
2. Use Better Auth for login, following its current docs for Cloudflare Workers
   and its Cloudflare D1 example (database: env.DB). Enable emailAndPassword.
3. Create Better Auth's core tables (user, session, account, verification) and
   my app's own tables as wrangler d1 migrations, never by hand in the console.
   Apply them locally only; I will apply them remotely myself.
4. Every query uses prepare() with ? placeholders and bind(). Never build SQL
   from strings.
5. Every route that reads or changes a user's data calls auth.api.getSession
   with the request headers, returns 401 without a session, and filters by
   session.user.id. Never trust a user id sent by the browser.
6. Read BETTER_AUTH_SECRET from env. Put a placeholder in .dev.vars, make sure
   .dev.vars is in .gitignore, and tell me to run wrangler secret put for
   production. Never write the secret into code or the Wrangler config.
7. When done, list the files you changed and how to test sign-up, sign-in,
   and that one user cannot see another user's data.

What not to skip

  • Hashed passwords. Never store a password as typed. Better Auth hashes them with scrypt; do not let an agent write its own login table “to keep it simple”.
  • Secrets out of git. wrangler secret put for production, .dev.vars locally, both kept out of your repository.
  • A session check on every data route. Login only helps if every request that reads someone’s data checks who is asking. Test it with two accounts in two browsers.
  • Migrations, not console edits. If you change a table by hand in production, your local copy and your migrations no longer match, and the next change breaks.

With data and login working, you can take money: the next chapter is Payments with Stripe Checkout.

Sources

The whole guide as one PDF to keep, with every prompt in one place, worksheets, fixes for when something breaks and the launch checklist.

Get the PDF
Read next