Back to guides

Updated 8 August 2026

Build custom auth in a Replit app


Replit Auth signs users in with existing Replit accounts on a Replit-branded page. Install Own Auth in the Replit backend to build custom authentication UI, create accounts for the app itself, and store its user and session records in Replit Postgres.

Compare Replit's built-in auth options

Replit Auth is the zero-setup option for apps that can use Replit accounts and Replit branding. Replit also offers managed Clerk authentication for app-owned accounts, custom branding, and separate development and production environments. Compare Own Auth and Clerk when choosing between a library in your backend and Replit's managed branded option.

Set up Own Auth in a Replit app

Every Replit App includes a development database, and Replit exposes its connection as DATABASE_URL. Install Own Auth, then apply its tables to that database.

Replit Shell
npm install own-auth cookie-parser express zod
npm install --save-dev @types/cookie-parser @types/express

In Replit Shell, DATABASE_URL points to the development database.

Replit Shell
npx own-auth migrate

Replit creates a separate production database when the app is published and applies development schema changes during publishing.

Create the auth instance

Add the token pepper through Replit Secrets, then create one Own Auth instance in the backend.

Replit Secrets
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
src/auth.ts
import { createOwnAuth } from "own-auth";

export const auth = createOwnAuth({
  tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER!,
});

Create sign-up and sign-in handlers

These Express handlers validate the request shape and store a completed Own Auth session in an HttpOnly cookie. Own Auth owns password policy, duplicate-account handling, credential checks, and authentication rate limits.

src/validation/auth.ts
import { z } from "zod";

export const signUpSchema = z.object({
  name: z.string().trim().min(1).optional(),
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export const signInSchema = z.object({
  email: z.string().trim().email(),
  password: z.string().min(1),
});
src/routes/auth.ts
import type { Request, Response } from "express";
import { AuthError, type SessionResult } from "own-auth";
import { auth } from "../auth";
import { signInSchema, signUpSchema } from "../validation/auth";

function setSessionCookie(response: Response, result: SessionResult) {
  response.cookie("own_auth_session", result.sessionToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    expires: result.session.expiresAt,
    path: "/",
  });
}

function sendAuthError(error: unknown, response: Response) {
  if (error instanceof AuthError) {
    return response
      .status(error.statusCode)
      .json({ error: error.safeMessage });
  }

  throw error;
}

export async function signUpHandler(
  request: Request,
  response: Response,
) {
  const payload = signUpSchema.parse(request.body);

  try {
    const result = await auth.signUpEmailPassword(payload);
    setSessionCookie(response, result);

    return response.status(201).json({
      user: {
        id: result.user.id,
        email: result.user.email,
        name: result.user.name,
      },
    });
  } catch (error) {
    return sendAuthError(error, response);
  }
}

export async function signInHandler(
  request: Request,
  response: Response,
) {
  const { email, password } = signInSchema.parse(request.body);

  try {
    const result = await auth.signInEmailPassword({ email, password });

    if (result.status === "mfa_required") {
      return response.json({
        status: result.status,
        challengeToken: result.challengeToken,
        methods: result.methods,
        expiresAt: result.expiresAt,
      });
    }

    setSessionCookie(response, result);
    return response.json({
      user: {
        id: result.user.id,
        email: result.user.email,
        name: result.user.name,
      },
    });
  } catch (error) {
    return sendAuthError(error, response);
  }
}

Own Auth returns invalid_credentials for an unknown email or incorrect password.

Register and protect the routes

Parse JSON and cookies before registering the auth routes. Every protected route reads the opaque cookie and verifies its current database session before using the returned user ID for application authorization. Keep these routes same-origin and add CSRF validation before introducing cookie-authenticated state changes.

src/server.ts
import cookieParser from "cookie-parser";
import express from "express";

import { auth } from "./auth";
import { signInHandler, signUpHandler } from "./routes/auth";

const app = express();

app.use(express.json());
app.use(cookieParser());

app.post("/auth/signup", signUpHandler);
app.post("/auth/signin", signInHandler);

app.get("/account", async (request, response) => {
  const token = request.cookies.own_auth_session;
  const current =
    typeof token === "string" ? await auth.getCurrentSession(token) : null;

  if (!current) {
    return response.status(401).json({ error: "Unauthorized" });
  }

  return response.json({
    user: {
      id: current.user.id,
      email: current.user.email,
      name: current.user.name,
    },
  });
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port);

Send branded account links

Add managed delivery when the app starts sending magic links or password-reset emails. baseUrl sets the public Replit app origin for those links, and OwnAuthManagedEmailProvider sends them through Own Auth Delivery.

Replit Secrets
APP_URL=https://your-app.example.com
OWN_AUTH_EMAIL_DELIVERY_KEY=replace-with-your-delivery-key
src/auth.ts
import {
  createOwnAuth,
  OwnAuthManagedEmailProvider,
} from "own-auth";

export const auth = createOwnAuth({
  tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER!,
  baseUrl: process.env.APP_URL!,
  emailProvider: new OwnAuthManagedEmailProvider({
    deliveryKey: process.env.OWN_AUTH_EMAIL_DELIVERY_KEY!,
  }),
});

Link existing app data

Replit does not publish a migration path from Replit Auth to custom authentication. A Replit account does not become an account in the app, and its active session cannot be transferred to Own Auth. Existing users must create a new app account before the backend can link the new Own Auth user ID to the existing application record.

  • Keep Replit sign-in available while existing users create new app accounts.
  • Link accounts only from a backend route that verifies both active sessions.
  • Reject any Replit or Own Auth user ID that is already linked to another record.
  • Remove Replit sign-in after the migration period.