Skip to main content
Image by FLY:D

Making magic links in Auth.js (NextAuth.js) work your way

Published

Auth.js (formally NextAuth.js) is a fantastic library for setting up authentication in your Node.js-based application.

And you’re lucky, because Auth.js has some excellent guides to setting up the library in your project, not to mention a variety of adapters to work with your data.

Auth.js prefers your database to be setup a very specific way. So what happens if you need to do something different? There are multiple reasons:

  • you prefer your database tables to be named differently, for example: users instead of user,
  • different column names, for example: user_id instead of userId,
  • this is going into a pre-existing project, so changing primary and foreign keys is not an option,
  • you’re cautious about security and want to throw in a few traps,
  • there’s extra data you want to store, or
  • you want/need to use a very specific database engine that’s not supported.

Fortunately there’s a way to make Auth.js do things your way.

Creating a custom adapter

Where you create your adapter in your project is up to you, but Auth.js needs to use it when it’s being initialized and that depends on your setup. Here’s some links to get you started:

For this guide I’ll be using Prisma.js to handle the database connections and queries, but whatever you choose just remember that the database client must be passed into the adapter.

import Email from "@auth/core/providers/email";
import { PrismaClient } from "@prisma/client";

import { MyAdapter } from "../../Adapter";

export default NextAuth({
  adapter: MyAdapter(PrismaClient), //  <-- db client goes in the adapter
  providers: [
    Email({
      ...
    }),
  ],
})

Speaking of Prisma, here’s the schema for the three tables that were created in the database before being pulled down. You might notice that I prefer my foreign keys to use the singular table name appended by _id (and timestamps to be appended with _at), which is different to the Auth.js standard:

model users {
  id          BigInt     @id @default(autoincrement()) @db.UnsignedBigInt
  name        String     @db.VarChar(255)
  email       String     @unique(map: "email") @db.VarChar(255)
  created_at  DateTime   @default(now()) @db.Timestamp(0)

  sessions    sessions[]
}

model sessions {
  id         BigInt    @id @default(autoincrement()) @db.UnsignedBigInt
  user_id    BigInt    @db.UnsignedBigInt
  token      String    @unique(map: "token") @db.VarChar(255)
  expires_at DateTime? @db.Timestamp(0)
  users      users     @relation(fields: [user_id], references: [id], onDelete: Cascade, map: "sessions_ibfk_1")

  @@index([user_id], map: "user_id")
}

model tokens {
  identifier String    @db.VarChar(255)
  token      String    @unique(map: "token") @db.VarChar(255)
  expires_at DateTime? @db.Timestamp(0)

  @@unique([identifier, token], map: "identifier_token")
}

Keep an eye on that identifier_token compound key, I’ll be using it later.

The golden rule

There is one important rule to remember when dealing with the adapter: the inputs and outputs belong to Auth.js, but the database queries are yours.

Here’s a basic adapter for Auth.js (and you should change MyAdapter to something else):

export default function MyAdapter(client) {
  return {
    async createUser(user) {
      // Create a new record in your user's table with the values in the user object
      return user
    },
    async getUser(id) {
      // Get the user with this id
      return user
    },
    async getUserByEmail(email) {
      // Get the user with this email
      return user
    },
    async updateUser({ id, ...data }) {
      // Update the user's data by their id
      return { id, ...data }
    },
    async deleteUser(id) {
       // Delete the user by their id
      return
    },
    async createSession({ sessionToken, userId, expires }) {
      // Create a new session record
      return { sessionToken, userId, expires }
    },
    async getSessionAndUser(sessionToken) {
      // Get the user and session token
      return { user, session }
    },
    async updateSession({ sessionToken, expires }) {
      // Update the session token
      return { sessionToken, expires }
    },
    async deleteSession(sessionToken) {
      // Delete the session token
      return
    },
    async createVerificationToken({ identifier, expires, token }) {
      // Create a new verification token
      return { identifier, expires, token }
    },
    async useVerificationToken({ identifier, token }) {
      // Delete the verification token (so it can't be used again)
      // For magic links this will called by the URL in the email
      return { identifier, token, expires }
    },
  }
}

You can go through and replace each comment with a database query to suit your schema. I’ve ignored getUserByAccount, linkAccount and unlinkAccount as I’m only interested in magic links for now, but it’s easy enough to add later if I want to add authentication for third-party sites.

Earlier I pointed out the identifier_token in the schema, which is a compound key between the identifier and token columns in the table. While your design might be different, this is a great way to show that rule of theirs verses yours for useVerificationToken:

We start with an empty async function that has their requested input and output:

async function useVerificationToken ({ identifier, token }) { // <-- theirs
  return { identifier, token, expires } // <-- also theirs
}

When you add your database query you can use Auth.js’ props, but make sure you return the correct key/value pairs:

async function useVerificationToken ({ identifier, token }) {
  const verificationToken = await client.tokens.delete({
    where: {
      identifier_token: { // <-- compound key
        identifier,
        token,
      },
    },
  });
  
  return {
    identifier,
    token,
    expires: verificationToken.expires_at // <-- their key, your value
  };
}

I’m using expires_at in my database, but Auth.js wants expires to be returned in the object, so I need to return it along with identifier and token.

I also have to treat identifier_token as an object in my query, but that’s what I get for using a compound key.

Finishing up

All of the functions in Auth.js work together so if you want to customize how the adapter works, regardless of your reasons, you need to keep Auth.js happy and return the keys it’s expecting. What happens between the start of each function and the return statement is up to you.

And you can always check out other Auth.js adapter packages and examine the different ways to make your own.