Back to guides

Updated 8 August 2026

Own Auth with AdonisJS


Add authentication to AdonisJS with controllers, VineJS validation, encrypted HttpOnly session cookies, and route middleware. Own Auth verifies credentials and stores users and sessions in Postgres while AdonisJS owns the HTTP boundary.

Install Own Auth

Terminal
npm install own-auth

Apply the database schema

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
Terminal
npx own-auth migrate

Own Auth creates own_auth_ prefixed tables. Keep those tables out of Lucid models and AdonisJS migrations.

Create the auth instance

Add a stable token pepper to .env and the AdonisJS environment schema, then create the shared Own Auth instance.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
start/env.ts
import { Env } from "@adonisjs/core/env";

export default await Env.create(new URL("../", import.meta.url), {
  DATABASE_URL: Env.schema.string(),
  OWN_AUTH_TOKEN_PEPPER: Env.schema.string(),
});
app/services/own_auth.ts
import { createOwnAuth, type User } from "own-auth";
import env from "#start/env";

const auth = createOwnAuth({
  tokenPepper: env.get("OWN_AUTH_TOKEN_PEPPER"),
});

export function toPublicUser({ id, email, name, imageUrl }: User) {
  return { id, email, name, imageUrl };
}

export default auth;

toPublicUser limits AdonisJS responses to id, email, name, and imageUrl instead of serializing the stored user record.

Validate credentials

VineJS checks the HTTP payload shape. Own Auth keeps control of password policy, duplicate-account handling, credential checks, and auth rate limits.

app/validators/auth.ts
import vine from "@vinejs/vine";

export const signUpValidator = vine.create({
  name: vine.string().trim().minLength(1).optional(),
  email: vine.string().trim().email(),
  password: vine.string().minLength(1),
});

export const signInValidator = vine.create({
  email: vine.string().trim().email(),
  password: vine.string().minLength(1),
});

Store the session cookie

app/services/own_auth_cookie.ts
import type { HttpContext } from "@adonisjs/core/http";
import app from "@adonisjs/core/services/app";
import type { SessionResult } from "own-auth";

export const sessionCookieName = "own_auth_session";

const cookieOptions = {
  httpOnly: true,
  path: "/",
  sameSite: "lax" as const,
  secure: app.inProduction,
};

export function setSessionCookie(
  response: HttpContext["response"],
  result: SessionResult,
) {
  response.encryptedCookie(sessionCookieName, result.sessionToken, {
    ...cookieOptions,
    maxAge: Math.max(0, result.session.expiresAt.getTime() - Date.now()),
  });
}

export function clearSessionCookie(response: HttpContext["response"]) {
  response.encryptedCookie(sessionCookieName, "", {
    ...cookieOptions,
    maxAge: 0,
  });
}

Add the auth controller

Set the session cookie only after a complete sign-in. An MFA result returns its challenge token, methods, and expiry as a successful first-factor response.

app/controllers/auth_controller.ts
import type { HttpContext } from "@adonisjs/core/http";
import auth, { toPublicUser } from "#services/own_auth";
import {
  clearSessionCookie,
  sessionCookieName,
  setSessionCookie,
} from "#services/own_auth_cookie";
import { signInValidator, signUpValidator } from "#validators/auth";

export default class AuthController {
  async signUp({ request, response }: HttpContext) {
    const payload = await request.validateUsing(signUpValidator);
    const result = await auth.signUpEmailPassword(payload);

    setSessionCookie(response, result);
    const user = toPublicUser(result.user);
    return response.status(201).send({ user });
  }

  async signIn({ request, response }: HttpContext) {
    const payload = await request.validateUsing(signInValidator);
    const result = await auth.signInEmailPassword(payload);

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

    setSessionCookie(response, result);
    const user = toPublicUser(result.user);
    return response.send({ user });
  }

  async current({ request, response }: HttpContext) {
    const token = request.encryptedCookie(sessionCookieName);
    const current = token ? await auth.getCurrentSession(token) : null;

    if (!current) return response.unauthorized({ error: "Unauthorized" });
    const user = toPublicUser(current.user);
    return response.send({ user });
  }

  async signOut({ request, response }: HttpContext) {
    const token = request.encryptedCookie(sessionCookieName);
    if (token) await auth.signOut(token);

    clearSessionCookie(response);
    return response.noContent();
  }
}

auth.signUpEmailPassword creates the Own Auth user and password account together. Do not write a second password record through Lucid.

Register the routes

start/routes.ts
import router from "@adonisjs/core/services/router";
import { controllers } from "#generated/controllers";

router
  .group(() => {
    router.post("/signup", [controllers.Auth, "signUp"]);
    router.post("/signin", [controllers.Auth, "signIn"]);
    router.get("/session", [controllers.Auth, "current"]);
    router.post("/signout", [controllers.Auth, "signOut"]);
  })
  .prefix("/auth");

Protect routes with middleware

Verify the cookie with auth.getCurrentSession, then bind the result to the current request.

app/middleware/own_auth_middleware.ts
import type { HttpContext } from "@adonisjs/core/http";
import type { NextFn } from "@adonisjs/core/types/http";
import auth from "#services/own_auth";
import { sessionCookieName } from "#services/own_auth_cookie";

export default class OwnAuthMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    const token = ctx.request.encryptedCookie(sessionCookieName);
    const current = token ? await auth.getCurrentSession(token) : null;

    if (!current) {
      return ctx.response.unauthorized({ error: "Unauthorized" });
    }

    ctx.containerResolver.bindValue("ownAuth", current);
    return next();
  }
}
types/own_auth.ts
import type { CurrentSession } from "own-auth";

declare module "@adonisjs/core/types" {
  interface ContainerBindings {
    ownAuth: CurrentSession;
  }
}
start/kernel.ts
import router from "@adonisjs/core/services/router";

export const middleware = router.named({
  ownAuth: () => import("#middleware/own_auth_middleware"),
});
start/routes.ts
import router from "@adonisjs/core/services/router";
import { toPublicUser } from "#services/own_auth";
import { middleware } from "#start/kernel";

router
  .get("/account", async ({ containerResolver }) => {
    const current = await containerResolver.make("ownAuth");
    const user = toPublicUser(current.user);
    return { user };
  })
  .use(middleware.ownAuth());

Use current.user.id for route-specific ownership and permission checks.

Return safe auth errors

Map expected AuthError values in the global exception handler. Unknown errors continue through the normal AdonisJS error path.

app/exceptions/handler.ts
import { ExceptionHandler, type HttpContext } from "@adonisjs/core/http";
import { AuthError } from "own-auth";

export default class HttpExceptionHandler extends ExceptionHandler {
  async handle(error: unknown, ctx: HttpContext) {
    if (error instanceof AuthError) {
      return ctx.response.status(error.statusCode).send({
        error: {
          code: error.code,
          message: error.safeMessage,
        },
      });
    }

    return super.handle(error, ctx);
  }

  async report(error: unknown, ctx: HttpContext) {
    if (error instanceof AuthError) return;
    return super.report(error, ctx);
  }
}

Add magic links

Magic-link generation needs the application origin. Add APP_URL to the environment schema and .env, then replace the auth initialization in app/services/own_auth.ts. Keep this origin explicit instead of deriving it from request.completeUrl() or the inbound Host header. Request values may include a development port and depend on proxy configuration; a fixed origin keeps emailed links stable and prevents caller-controlled hosts from entering authentication URLs.

start/env.ts
import { Env } from "@adonisjs/core/env";

export default await Env.create(new URL("../", import.meta.url), {
  DATABASE_URL: Env.schema.string(),
  OWN_AUTH_TOKEN_PEPPER: Env.schema.string(),
  APP_URL: Env.schema.string({ format: "url" }),
});
.env
APP_URL=http://localhost:3333
app/services/own_auth.ts
const auth = createOwnAuth({
  tokenPepper: env.get("OWN_AUTH_TOKEN_PEPPER"),
  baseUrl: env.get("APP_URL"),
});

After configuring an email provider, request and verify magic links through the same auth instance. Complete verification sets the session cookie; MFA returns its challenge payload.

app/services/magic_links.ts
import type { HttpContext } from "@adonisjs/core/http";
import auth, { toPublicUser } from "#services/own_auth";
import { setSessionCookie } from "#services/own_auth_cookie";

export function requestMagicLink(email: string) {
  return auth.requestMagicLink({ email });
}

export async function verifyMagicLink(
  token: string,
  response: HttpContext["response"],
) {
  const result = await auth.verifyMagicLink({ token });

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

  setSessionCookie(response, result);
  const user = toPublicUser(result.user);
  return {
    status: result.status,
    user,
  };
}