How to build an authentication system from scratch

Thu Jan 16 2025

Learn how to build an authentication system from scratch using Arctic for OAuth.

AuthOAuthArctic

Introduction

Building an authentication system from scratch can be a daunting task, but with the right tools and techniques, it can be a rewarding experience. In this guide, we'll walk through how to build an authentication system from scratch using Arctic for OAuth.

What is Arctic?

Arctic is a collection of OAuth 2.0 clients for popular providers. Only the authorization code flow is supported. Built on top of the Fetch API, it's light weight, fully-typed, and runtime-agnostic.

Getting Started

To get started with Arctic, you can install using following command:

bun add @oslojs/crypto @oslojs/encoding arctic
bun add -d prisma

Setting up the Prisma schema

First, let's set up the Prisma schema. Create a new file called schema.prisma and add the following code:

prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id       String    @id @default(cuid())
  name     String
  email    String    @unique
  image    String
  password String?
  accounts Account[]
  sessions Session[]
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
model Account {
  provider     String
  providerId   String
 
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId String
 
  @@id([provider, providerId])
}
 
model Session {
  sessionToken String   @unique
  expiresAt    DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId       String
 
  createdAt DateTime @default(now())
}

Then, add some scripts to your package.json:

package.json
{
  "scripts": {
    "db:generate": "prisma generate",
    "db:push": "prisma db push",
    "db:studio": "prisma studio"
  }
}

Now, run the following command to create the database:

Remember to set the DATABASE_URL environment variable.

bun db:push

Next, create the Prisma function in server/db.ts:

server/db.ts
import { PrismaClient } from '@prisma/client'
 
const createPrismaClient = () =>
  new PrismaClient({
    log:
      process.env.NODE_ENV === 'development'
        ? ['query', 'error', 'warn']
        : ['error'],
  })
 
const globalForPrisma = globalThis as unknown as {
  prisma: ReturnType<typeof createPrismaClient> | undefined
}
 
export const db = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
 
export type * from '@prisma/client'

Setting up the Auth API

Next, let's set up the Auth API. Create a new file called server/auth/index.ts and add the following code:

  • Generate a session token:
server/auth/index.ts
import { encodeBase32LowerCaseNoPadding } from '@oslojs/encoding'
 
export function generateSessionToken(): string {
  const bytes = new Uint8Array(20)
  crypto.getRandomValues(bytes)
  const token = encodeBase32LowerCaseNoPadding(bytes)
  return token
}
  • Create session helper functions:

In this function, we create a new session in the database with the given token and user ID.

server/auth/index.ts
import { sha256 } from '@oslojs/crypto/sha2'
import { encodeHexLowerCase } from '@oslojs/encoding'
 
const EXPIRES_IN = 1000 * 60 * 60 * 24 * 30 // 30 days
 
export const createSession = async (
  token: string,
  userId: string,
): Promise<Session> => {
  const session = {
    sessionToken: encodeHexLowerCase(sha256(new TextEncoder().encode(token))),
    expiresAt: new Date(Date.now() + EXPIRES_IN),
    user: { connect: { id: userId } },
  }
 
  return await db.session.create({ data: session })
}
  • Validate a session token:

In this function, we check if the session token exists in the database and if it has expired. If the token is valid, we return the user and the expiration date. If the token has expired, we delete it from the database and return the current date.

server/auth/index.ts
export const validateSessionToken = async (
  token: string,
): Promise<SessionValidation> => {
  const sessionToken = encodeHexLowerCase(
    sha256(new TextEncoder().encode(token)),
  )
  const result = await db.session.findUnique({
    where: { sessionToken },
    include: { user: true },
  })
  if (!result) return { expires: new Date(Date.now()) }
 
  const { user, ...session } = result
  if (Date.now() >= session.expiresAt.getTime()) {
    await db.session.delete({ where: { sessionToken } })
    return { expires: new Date(Date.now()) }
  }
 
  if (Date.now() >= session.expiresAt.getTime() - EXPIRES_IN / 2) {
    session.expiresAt = new Date(Date.now() + EXPIRES_IN)
    await db.session.update({
      where: { sessionToken },
      data: { expiresAt: session.expiresAt },
    })
  }
 
  return { user, expires: session.createdAt }
}
 
export interface SessionValidation {
  user?: User
  expires: Date
}
  • Create a invalidation session function:

In this function, we delete the session from the database using the session token.

server/auth/index.ts
export const invalidateSession = async (token: string): Promise<void> => {
  const sessionToken = encodeHexLowerCase(
    sha256(new TextEncoder().encode(token)),
  )
  await db.session.delete({ where: { sessionToken } })
}

Setting up the OAuth API

Next, let's set up the OAuth API. Create a new file called server/auth/oauth.ts and add the following code:

  • First, let's create a class called OAuth that takes the provider, client ID, client secret, and callback URL as arguments:

    Note: in this example, we use Discord as the provider. So that, remember to add DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET to your environment variables.

    Note 2: You can add more providers base on Arctic documentaion.

server/auth/oauth.ts
import { Discord } from 'arctic'
 
import { env } from '@/env'
 
export class OAuth {
  private name: string
  private provider: Discord
  private scopes: string[]
  private oauthUser: { id: string; email: string; name: string; image: string }
 
  constructor(provider: string, callback_url: string) {
    this.oauthUser = { id: '', email: '', name: '', image: '' }
 
    switch (provider) {
      case 'discord':
        this.name = 'discord'
        this.provider = new Discord(
          env.DISCORD_ID,
          env.DISCORD_SECRET,
          callback_url,
        )
        this.scopes = ['identify', 'email']
        break
      default:
        throw new Error(`Provider ${provider} not supported`)
    }
  }
}
  • Second, let's create a function called getOAuthURL that generates the OAuth URL for the provider:
server/auth/oauth.ts
class OAuth {
  // ...
 
  public getOAuthUrl(): { url: URL; state: string } {
    const state = generateState()
 
    const url =
      this.provider.createAuthorizationURL.length === 3
        ? // @ts-expect-error - This is a hack to make the types work
          this.provider.createAuthorizationURL(state, null, this.scopes)
        : // @ts-expect-error - This is a hack to make the types work
          this.provider.createAuthorizationURL(state, this.scopes)
 
    return { url, state }
  }
}
  • Third, let's create a function called callback that handles the OAuth callback:
server/auth/oauth.ts
import { db } from '@/server/db'
 
class OAuth {
  // ...
 
  public async callback(code: string) {
    const tokens =
      this.provider.validateAuthorizationCode.length == 2
        ? await this.provider.validateAuthorizationCode(code, '')
        : // @ts-expect-error - This is a hack to make the types work
          await this.provider.validateAuthorizationCode(code)
 
    switch (this.name) {
      case 'discord':
        await this.discord(tokens.accessToken())
        break
    }
 
    return await this.createUser()
  }
}
  • Fourth, let's create a function called discord that fetches the user's information from Discord:
server/auth/oauth.ts
class OAuth {
  // ...
 
  private async discord(token: string) {
    // prettier-ignore
    interface DiscordUser { id: string; email: string; username: string; avatar: string }
    this.oauthUser = await fetch('https://discord.com/api/users/@me', {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json() as Promise<DiscordUser>)
      .then((account) => ({
        id: account.id,
        name: account.username,
        email: account.email,
        image: `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.png`,
      }))
      .catch(() => {
        throw new Error('Failed to fetch user data from Discord')
      })
  }
}
  • Finally, let's create a function called createUser that creates a new user in the database:
server/auth/oauth.ts
class OAuth {
  // ...
  private async createUser() {
    const { id, email, name, image } = this.oauthUser
    const create = { provider: this.name, providerId: id }
 
    const account = await db.account.findUnique({
      where: { provider_providerId: { provider: this.name, providerId: id } },
    })
    let user = await db.user.findFirst({ where: { email } })
 
    if (!account && !user)
      user = await db.user.create({
        data: { email, name, image, accounts: { create } },
      })
    else if (!account && user)
      user = await db.user.update({
        where: { email },
        data: { accounts: { create } },
      })
 
    if (!user) throw new Error(`Failed to sign in with ${this.name}`)
    return user
  }
}

Consuming the OAuth API

server/auth/oauth.ts
import { authEnv } from '@yuki/auth/env'
import { db } from '@yuki/db'
import { Discord, generateState, GitHub } from 'arctic'
 
export class OAuth {
  private name: string
  private provider: Discord | GitHub
  private scopes: string[]
  private oauthUser: { id: string; email: string; name: string; image: string }
 
  constructor(provider: string, callback_url: string) {
    this.oauthUser = { id: '', email: '', name: '', image: '' }
 
    switch (provider) {
      case 'discord':
        this.name = 'discord'
        this.provider = new Discord(
          env.DISCORD_ID,
          env.DISCORD_SECRET,
          callback_url,
        )
        this.scopes = ['identify', 'email']
        break
      default:
        throw new Error(`Provider ${provider} not supported`)
    }
  }
 
  public getOAuthUrl(): { url: URL; state: string } {
    const state = generateState()
 
    const url =
      this.provider.createAuthorizationURL.length === 3
        ? // @ts-expect-error - This is a hack to make the types work
          this.provider.createAuthorizationURL(state, null, this.scopes)
        : // @ts-expect-error - This is a hack to make the types work
          this.provider.createAuthorizationURL(state, this.scopes)
 
    return { url, state }
  }
 
  public async callback(code: string) {
    const tokens =
      this.provider.validateAuthorizationCode.length == 2
        ? await this.provider.validateAuthorizationCode(code, '')
        : // @ts-expect-error - This is a hack to make the types work
          await this.provider.validateAuthorizationCode(code)
 
    switch (this.name) {
      case 'discord':
        await this.discord(tokens.accessToken())
        break
    }
 
    return await this.createUser()
  }
 
  private async createUser() {
    const { id, email, name, image } = this.oauthUser
    const create = { provider: this.name, providerId: id }
 
    const account = await db.account.findUnique({
      where: { provider_providerId: { provider: this.name, providerId: id } },
    })
    let user = await db.user.findFirst({ where: { email } })
 
    if (!account && !user)
      user = await db.user.create({
        data: { email, name, image, accounts: { create } },
      })
    else if (!account && user)
      user = await db.user.update({
        where: { email },
        data: { accounts: { create } },
      })
 
    if (!user) throw new Error(`Failed to sign in with ${this.name}`)
    return user
  }
 
  private async discord(token: string) {
    // prettier-ignore
    interface DiscordUser { id: string; email: string; username: string; avatar: string }
    this.oauthUser = await fetch('https://discord.com/api/users/@me', {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json() as Promise<DiscordUser>)
      .then((account) => ({
        id: account.id,
        name: account.username,
        email: account.email,
        image: `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.png`,
      }))
      .catch(() => {
        throw new Error('Failed to fetch user data from Discord')
      })
  }
}

Setting up the API routes

We use Next.js in this example.

Frist, cretae some helper functions in lib/auth/server.ts:

lib/auth/server.ts
'use server'
 
import { cache } from 'react'
import { cookies } from 'next/headers'
 
import type { SessionValidation } from '@/server/auth'
import { env } from '@/env'
import {
  createSession,
  generateSessionToken,
  invalidateSession,
  validateSessionToken,
} from '@/server/auth'
 
const KEY = 'auth_token'
 
export const auth = cache(async (): Promise<SessionValidation> => {
  const token = (await cookies()).get(KEY)?.value ?? ''
  if (!token) return { expires: new Date(Date.now()) }
  return validateSessionToken(token)
})
 
export const signIn = async (userId: string) => {
  const token = generateSessionToken()
  const session = await createSession(token, userId)
  ;(await cookies()).set(KEY, token, {
    httpOnly: true,
    path: '/',
    secure: env.NODE_ENV === 'production',
    sameSite: 'lax',
    expires: session.expiresAt,
  })
}
 
export const signOut = async () => {
  const token = (await cookies()).get(KEY)?.value ?? ''
  if (!token) return
 
  await invalidateSession(token)
  ;(await cookies()).set(KEY, '', {
    httpOnly: true,
    path: '/',
    secure: env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 0,
  })
}

Next, let's set up the API routes. Create a new file called app/api/auth/[...auth]/route.ts and add the following code:

app/api/auth/[...auth]/route.ts
import type { NextRequest } from 'next/server'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { OAuth2RequestError } from 'arctic'
 
import { signIn } from '@/lib/auth/server'
import { OAuth } from '@/server/auth/oauth'
 
export const GET = async (
  req: NextRequest,
  { params }: { params: Promise<{ auth: [string, string] }> },
) => {
  const nextUrl = new URL(req.url)
 
  const [provider, isCallback] = (await params).auth
  const callbackUrl = `${nextUrl.origin}/api/auth/${provider}/callback`
 
  const authProvider = new OAuth(provider, callbackUrl)
 
  if (!isCallback) {
    const { url, state } = authProvider.getOAuthUrl()
    ;(await cookies()).set('oauth_state', `${state}`)
 
    return NextResponse.redirect(new URL(`${url}`, nextUrl))
  }
 
  try {
    const code = nextUrl.searchParams.get('code') ?? ''
    const state = nextUrl.searchParams.get('state') ?? ''
    const storedState = req.cookies.get('oauth_state')?.value ?? ''
    ;(await cookies()).delete('oauth_state')
 
    if (!code || !state || state !== storedState)
      throw new Error('Invalid state')
 
    const user = await authProvider.callback(code)
    await signIn(user.id)
 
    return NextResponse.redirect(new URL('/', nextUrl))
  } catch (e) {
    if (e instanceof OAuth2RequestError)
      return NextResponse.json({ error: e.message }, { status: Number(e.code) })
    else if (e instanceof Error)
      return NextResponse.json({ error: e.message }, { status: 500 })
    else
      return NextResponse.json(
        { error: 'An unknown error occurred' },
        { status: 500 },
      )
  }
}

If you want to get session data in the client side, you can create a hook in lib/auth/react.tsx

lib/auth/react.tsx
'use client'
 
import { createContext, use } from 'react'
 
import type { SessionValidation } from '@/server/auth'
 
const sessionContext = createContext<SessionValidation | undefined>(undefined)
 
export const SessionProvider: React.FC<
  Readonly<{
    session: SessionValidation
    children: React.ReactNode
  }>
> = ({ session, children }) => (
  <sessionContext.Provider value={session}>{children}</sessionContext.Provider>
)
 
export const useSession = () => {
  const context = use(sessionContext)
  if (!context) throw new Error('useSession must be used within a SessionProvider')
  return context
}

Then wrap your app with SessionProvider in app/layout.tsx

app/layout.tsx
import { SessionProvider } from '@/lib/auth/react'
import { auth } from '@/lib/auth/server'
 
const RootLayout: React.FC<React.PropsWithChildren> = ({ children }) => {
  const session = auth()
  return <SessionProvider session={session}>{children}</SessionProvider>
}

Now, you can use useSession hook in your components to get session data.

app/page.tsx
'use client'
 
import { useSession } from '@/lib/auth/react'
 
const Home: React.FC = () => {
  const session = useSession()
 
  return (
    <div>
      {session.user ? (
        <div>
          <p>Welcome, {session.user.name}</p>
          <button onClick={signOut}>Sign Out</button>
        </div>
      ) : (
        <button onClick={signIn}>Sign In</button>
      )}
    </div>
  )
}

Conclusion

In this guide, we've walked through how to build an authentication system from scratch using Arctic for OAuth. By following these steps, you can create a secure and reliable authentication system for your application. If you have any questions or need further assistance, feel free to reach out to the Arctic community for support.

Resources