Skip to content
IRONDOME
Docs menu

GATES

Add GATES to Better Auth

Connect GATES to Better Auth 1.7.7, verify email ownership and check access on protected routes.

On this page

Add GATES to a new Better Auth application without replacing its passwords, sessions or verification links. This guide uses Better Auth 1.7.7, PostgreSQL and GATES' online ownership backend.

After setup, signup screening happens on the server. A canonical email claim is created only after Better Auth proves ownership. A second verified account cannot claim the same canonical email within your tenant.

If your application already has users, start with existing-user migration. Do not put live accounts behind the access check before their ownership records and exact delivery addresses are ready.

Before you start

  • Use Node.js 22 or newer and an ESM application.
  • Follow installation to obtain the current GATES artifact. The package name is irondome-gates; the product name is GATES.
  • Install the pinned Better Auth release and your PostgreSQL driver:
Terminal
npm install --save-exact better-auth@1.7.7 pg@8.23.1
  • Provide a server-side IronDome API key, a Better Auth secret, and your existing email sender. Never put either secret in a browser bundle.
  • Choose one stable tenant ID in server configuration. Do not accept it from a signup form. This guide uses one tenant per application.

The initial path is verified email/password authentication. Additional providers and plugins have separate compatibility requirements; finish this path before adding them.

1. Prepare the private database schema

Follow database setup to create a trusted schema, restrict its roles and apply GATES migrations 001, 002, 003, 005 and 006, in that order. Migration 003 stores durable Better Auth verification bindings; 006 enables the online backend.

Configure Better Auth's PostgreSQL pool with trusted_auth as its search path. The GATES stores explicitly use that schema, while Better Auth's own tables and migration runner use the pool's search path. All must resolve to the same schema. Use finite connection, query, statement and lock timeouts. Keep the schema name a server-controlled constant.

Better Auth also needs its own tables, including GATES' private gateDeliveryEmail user field. Generate these from the final integration options in the next step, not from a separate minimal Better Auth configuration.

2. Run the local starter

The executable Node server provides /api/auth/* and a live-checked GET /api/protected route. It does not need a TypeScript loader. Run these commands from the repository root after installation and the GATES migrations above:

Terminal
cp examples/docs/.env.example examples/docs/.env
chmod 600 examples/docs/.env

Edit the file before continuing. Set the restricted runtime DATABASE_URL, the schema-owner MIGRATION_DATABASE_URL, your IronDome key, a stable tenant ID and a random Better Auth secret. Generate a secret locally with:

Terminal
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

Keep BETTER_AUTH_URL=http://127.0.0.1:3000/api/auth for this starter. Both database pools explicitly select trusted_auth,pg_catalog; the migration command uses only the migration URL and the server uses only the runtime URL.

Apply Better Auth's own tables, then apply the runtime grants from database setup after both sets of migrations:

Terminal
node --env-file=examples/docs/.env examples/docs/better-auth-server.mjs migrate

This command applies the final integration schema and rejects changes marked unsafe. It is for a new database, not an unattended production upgrade. After reviewing and applying the runtime grants, start the server:

Terminal
node --env-file=examples/docs/.env examples/docs/better-auth-server.mjs start

The server binds only to 127.0.0.1 and refuses NODE_ENV=production. Instead of sending email, it writes messages into a new private temporary directory printed at startup. The directory is accessible only to your OS user; each message file has mode 0600. Open a captured JSON file locally and follow its verification URL. Never upload these files or put their URLs in logs. Delete the directory when testing is complete. This is development mail capture, not production delivery.

Without a session, the protected route must deny access:

Terminal
curl -i http://127.0.0.1:3000/api/protected

Expected: 401 and {"allowed":false}. For a local smoke test, replace the address below with an inbox you control and choose a development-only password:

Terminal
curl -i 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":"Test user","email":"you@example.com","password":"local-test-password-123"}'

Open the newly captured message locally and visit its verification URL. Then sign in with the same address and password. The cookie jar is sensitive, so restrict its permissions before writing it:

Terminal
umask 077
COOKIE_JAR=$(mktemp)
curl -i -c "$COOKIE_JAR" \
  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":"you@example.com","password":"local-test-password-123"}'
curl -i -b "$COOKIE_JAR" http://127.0.0.1:3000/api/protected
rm "$COOKIE_JAR"

After successful verification and sign-in, the protected route returns 200 and {"allowed":true}. Keep these commands on a private development machine; do not use production passwords in shell commands. Signup screening calls the real IronDome API and uses your API quota; simply starting the server does not perform an email check. Complete the lifecycle checklist below before production.

3. Integrate with your application

For a framework integration or production mail sender, use the typed Better Auth setup module in this repository as your better-auth.ts module. It exports configureBetterAuthApplication, which accepts your PostgreSQL pool and email callback, constructs the online engine and durable proof store, and returns migration, startup and maintenance functions.

Connect it to your application's existing services:

TypeScript
import { configureBetterAuthApplication } from './better-auth.js';

const application = configureBetterAuthApplication({
  pool,
  tenantId: 'my-application',
  baseURL: 'https://app.example.com/api/auth',
  secret: config.betterAuthSecret,
  ironDomeApiKey: config.ironDomeApiKey,
  async sendVerificationEmail({ user, url }) {
    await mailer.send({
      to: user.email,
      subject: 'Verify your email address',
      text: `Verify your email address: ${url}`,
    });
  },
});

Here pool, config and mailer are your host application's services, not exports from GATES. The callback must resolve only after your sender accepts the message; propagate delivery failures rather than ignoring them. Send to user.email exactly as supplied. Do not send to a canonicalized address, log the verification URL or place the token in analytics events.

During the reviewed schema deployment, run:

TypeScript
await application.migrateAuthSchema();

This applies Better Auth's schema migration using the integration options and refuses migrations marked unsafe. Run schema changes as a deployment step with appropriate database privileges, not on every request or independently in every replica. Apply runtime grants after the Better Auth tables exist as well as the GATES tables. Use the same schema/configuration when starting the runtime with its restricted database role.

Start the application after both sets of migrations complete:

TypeScript
const bound = await application.start();

Startup loads or initializes the tenant's canonical policy. For a new tenant the built-in policy is used. Existing tenant policy changes require the migration workflow, not a changed environment variable.

4. Mount only the bound handler

In a Fetch-compatible framework, route /api/auth/* to bound.handler(request). For Node HTTP servers, the example exposes bound.nodeHandler, created with Better Auth's official toNodeHandler:

TypeScript
import { createServer } from 'node:http';

const server = createServer((request, response) => {
  void bound.nodeHandler(request, response).catch(() => {
    if (!response.headersSent) response.writeHead(503);
    response.end();
  });
});
server.listen(3000, '127.0.0.1');

This minimal server sends all routes to the auth handler. An application with other routes should mount it only under /api/auth/*. Your reverse proxy must preserve the intended origin and cookie behavior. Do not expose a second route using raw auth.handler, replace the returned safeguard options, disable CSRF or origin checks, or bypass the bound handler for authentication operations.

5. Check live ownership before granting access

Use bound.getActiveSession in protected server routes, before provisioning a resource, granting a trial or accessing application data:

TypeScript
const session = await bound.getActiveSession(request.headers);
if (!session) return new Response('Unauthorized', { status: 401 });

return Response.json({ allowed: true });

The helper returns null when no session exists. Invalid ownership and unavailable storage throw a denial or availability error. Route those errors through your server's error boundary; do not catch them and fall back to a raw or cached Better Auth session. A successful check does not replace your application's own resource permissions.

The browser can continue using Better Auth's normal signup, sign-in, changeEmail and deletion calls. GATES does not need a client-side email checker to enforce these rules.

6. Verify the complete lifecycle

Use accounts and inboxes you control in a test environment:

Test Expected result
Sign up, before opening the verification link No canonical ownership claim and no protected access.
Open the valid link, then sign in The verified user owns its canonical address and the live session check succeeds.
Register another verified account with an alias of that address The new user cannot take the existing canonical claim. No account is merged or logged in by matching email alone.
Submit an address detected as disposable Screening prevents verification delivery and active ownership.
Request an email change from a fresh session The old address remains active until the new address is verified. A conflicting target does not replace it.
Delete the account Ownership is terminally revoked before provider cleanup. An old session cannot pass the live check.

Better Auth may return a generic successful signup response even when a mail callback fails. A pending user row can remain, but that response does not mean verification mail was sent, a claim was granted or application access is allowed. Offer a rate-limited resend path and inspect the protected access result, not just the signup HTTP status.

Maintain verification records and recover deletion

Schedule application.deleteExpiredProofs() in your normal trusted maintenance job. Proof records contain exact delivery addresses and token fingerprints; apply your retention and backup policies to them. Never log raw verification tokens.

If deletion revokes ownership but provider cleanup fails, retry the authenticated deletion while its provider credentials remain usable. If partial cleanup has already removed those credentials, a trusted operator/job may call bound.resumeDeletion({ userId }). It requires the user to be terminally revoked. Never expose that function as an unauthenticated endpoint or reuse the deleted stable user ID.

Supported configuration and next steps

The adapter is pinned to Better Auth 1.7.7. It owns the security-sensitive auth options and rejects unsupported overrides. Account linking, secondary session storage, background delivery handlers and arbitrary additional plugins are outside this setup. OTP, magic-link and passkey verification alternatives are not qualified by this guide.

For OAuth/OIDC, explicitly trust only providers whose verified-email assertion establishes ownership. The adapter supports a constrained Generic OAuth setup; built-in social providers need their own qualification. Do not assume enabling an arbitrary provider makes its email claim trustworthy.

Read deployment requirements before production and custom canonical rules before changing normalization. Provider references: Better Auth Node integration, database configuration, and email/password authentication. GATES' pinned adapter contract takes precedence over examples for newer Better Auth versions.