npm.io
2.0.0 • Published 4d ago

@hallaxius/auth

Licence
MIT
Version
2.0.0
Deps
1
Size
1.8 MB
Vulns
0
Weekly
68

@hallaxius/auth

Plug-and-play Discord OAuth2 authentication for Bun, Next.js, and any Node/edge runtime.

License: MIT npm version Bundle Size npm downloads Last Commit

Features

  • Discord OAuth2 — Authorization code flow with CSRF protection (Web Crypto HMAC)
  • Elysia plugindiscordAuth(config) with macros auth, optionalAuth, requireRole
  • Standalone mode — For Next.js App Router, Node.js, or any edge runtime
  • Route guardswithAuth, withOptionalAuth, withRole(roles) for route handlers
  • Edge/Next.js middlewaremiddlewareAuth, middlewareRole, combine, nextAuth, nextRole
  • User persistence — Pluggable UserStorage interface (implement for your DB)
  • JWT sessions — Stateless, edge-compatible (uses jose)
  • Server sessions — In-memory Map with TTL (dev/light use)
  • Auto-join guildDiscordClient.addMember() to add users to your server after login
  • Edge compatible — Web Crypto API, zero Node dependencies
  • Zero unnecessary deps — Only jose

Installation

bun add @hallaxius/auth
# npm install @hallaxius/auth
# pnpm add @hallaxius/auth
# yarn add @hallaxius/auth

Peer dependency: next (optional, required for nextAuth/nextRole)

Elysia plugin available at @hallaxius/auth/elysia — requires elysia >= 1.4.29 and @elysiajs/jwt installed separately


Table of Contents


Quick Start

Elysia (Plugin)
import { Elysia } from "elysia"
import { discordAuth } from "@hallaxius/auth/elysia"

const app = new Elysia()
  .use(discordAuth({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: {
      type: "jwt",
      secret: process.env.JWT_SECRET!,
    },
  }))
  .get("/dashboard", ({ user }) => `Welcome, ${user.username}!`, {
    auth: true,
  })
  .listen(3000)
Elysia (Wrapper)
import { Discord } from "@hallaxius/auth"

const app = new Discord({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})

app.get("/dashboard", ({ user }) => `Welcome, ${user.username}!`, {
  auth: true,
})
app.listen(3000)

Note: Discord class wrapper is deprecated. Use discordAuth from @hallaxius/auth/elysia instead.

Next.js (Standalone Route Handlers)
// lib/auth.ts
import { auth } from "@hallaxius/auth"

export const { handleLogin, handleCallback, handleLogout, handleMe } = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})
// app/auth/discord/route.ts
import { handleLogin } from "@/lib/auth"
export const GET = handleLogin
// app/auth/discord/callback/route.ts
import { handleCallback } from "@/lib/auth"
export const GET = handleCallback
// app/auth/discord/logout/route.ts
import { handleLogout } from "@/lib/auth"
export const GET = handleLogout
Next.js (Middleware)
// middleware.ts
import { nextAuth, nextRole, combine } from "@hallaxius/auth"

export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*"],
}

export default combine(
  nextAuth({
    secret: process.env.JWT_SECRET!,
    loginUrl: "/auth/discord",
    publicPaths: ["/", "/auth/*"],
  }),
  nextRole({
    secret: process.env.JWT_SECRET!,
    loginUrl: "/auth/discord",
    roles: { "/admin/*": ["admin"] },
  }),
)

Quick Start (v1.1+) — Presets

Simplified with Presets

The v2.0.0 introduced Security Features (CSRF, brute-force, MFA, auto-refresh, guild role sync, type-safe routes) and moved the Elysia plugin to a sub-path. The v1.1.0 introduced presets for common configurations.

SPA (React, Vue, Svelte, etc.)
import { Elysia } from "elysia"
import { discordAuth } from "@hallaxius/auth/elysia"

const app = new Elysia()
    .use(discordAuth.presets.spa({
        clientId: process.env.DISCORD_CLIENT_ID!,
        clientSecret: process.env.DISCORD_CLIENT_SECRET!,
        secret: process.env.JWT_SECRET!,
    }))
    .get("/dashboard", ({ user }) => `Welcome, ${user.username}!`, { auth: true })
    .listen(3000)
Server-Side (with UserStorage)
const app = new Elysia()
    .use(discordAuth.presets.server({
        clientId: process.env.DISCORD_CLIENT_ID!,
        clientSecret: process.env.DISCORD_CLIENT_SECRET!,
        secret: process.env.JWT_SECRET!,
        storage: myStorage,
    }))
    .get("/dashboard", ({ user }) => `Hi ${user.username}`, { auth: true })
    .listen(3000)
Next.js App Router
const app = new Elysia()
    .use(discordAuth.presets.nextjs({
        clientId: process.env.DISCORD_CLIENT_ID!,
        clientSecret: process.env.DISCORD_CLIENT_SECRET!,
        secret: process.env.JWT_SECRET!,
    }))
Edge Runtime (Workers, Deno)
const app = new Elysia()
    .use(discordAuth.presets.edge({
        clientId: process.env.DISCORD_CLIENT_ID!,
        clientSecret: process.env.DISCORD_CLIENT_SECRET!,
        secret: process.env.JWT_SECRET!,
    }))
Factory Pattern
import { from } from "@hallaxius/auth/elysia"

const app = from({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: { type: "jwt", secret: process.env.JWT_SECRET! },
})
Preset Configuration
Preset session.type secure sameSite Best For
spa jwt false lax React / Vue / Svelte (localhost dev)
server server true lax Traditional backends (requires storage)
nextjs jwt true lax Next.js App Router / Middleware
edge jwt true lax Cloudflare Workers, Deno, edge runtimes

Configuration

DiscordAuthConfig
interface DiscordAuthConfig {
  clientId: string
  clientSecret: string
  session: SessionConfig
  scopes?: DiscordScope[]
  prompt?: "consent" | "none"
  routes?: RoutesConfig
  callbacks?: Callbacks
  storage?: UserStorage
  meRoute?: string
  redirectUri?: string
  disablePKCE?: boolean
}
Field Type Default Description
clientId string Discord OAuth2 Client ID
clientSecret string Discord OAuth2 Client Secret
session SessionConfig Session configuration
scopes DiscordScope[] ["identify"] OAuth2 scopes
prompt "consent" | "none" "consent" OAuth2 prompt parameter
redirectUri string DISCORD_REDIRECT_URI env or auto-computed Full absolute callback URL registered in Discord Developer Portal. Overrides auto-computed {prefix}/callback. Falls back to process.env.DISCORD_REDIRECT_URI then auto-computed {prefix}/callback
routes RoutesConfig Custom route paths
callbacks Callbacks onSuccess / onError hooks
storage UserStorage Optional user persistence
meRoute string "/auth/me" Path for /me endpoint
disablePKCE boolean false Disable PKCE (S256) for server-side confidential clients
SessionConfig
interface SessionConfig {
  type: "jwt" | "server"
  secret: string
  expiresIn?: string | number
  cookieName?: string
  cookiePath?: string
  httpOnly?: boolean
  secure?: boolean
  sameSite?: "lax" | "strict" | "none"
}
Field Type Default Description
type "jwt" | "server" Session storage type
secret string HMAC secret for JWT signing
expiresIn string | number "7d" Expiration (e.g. "7d", "1h", 3600)
cookieName string "discord-auth-session" Cookie name
cookiePath string "/" Cookie path
httpOnly boolean true HttpOnly flag
secure boolean false Secure flag
sameSite "lax" | "strict" | "none" "lax" SameSite policy
RoutesConfig
interface RoutesConfig {
  prefix?: string
  callback?: string
  logout?: string
  error?: string
}
Field Type Default Description
prefix string "/auth/discord" Route prefix
callback string "/auth/discord/callback" Full callback path (used directly)
logout string "/auth/discord/logout" Full logout path (used directly)
error string "/auth/discord/error" Full error path (used directly)
Callbacks
interface Callbacks {
  onSuccess?: (
    user: DiscordUser,
    tokens: DiscordTokenResponse,
  ) => Promise<{ redirect?: string } | undefined>
  onError?: (
    error: Error,
    phase: "auth" | "callback" | "session",
  ) => Promise<{ redirect?: string } | undefined>
}
Edge Configs
interface EdgeSessionConfig {
  secret: string
  cookieName?: string
}

interface EdgeAuthConfig extends EdgeSessionConfig {
  loginUrl?: string
  publicPaths?: string[]
}

interface EdgeRoleConfig extends EdgeSessionConfig {
  loginUrl?: string
  roles: Record<string, string[]>
}
Field Type Default Description
secret string JWT secret (same as SessionConfig.secret)
cookieName string "discord-auth-session" Cookie name
loginUrl string "/auth/discord" Redirect URL for unauthenticated users
publicPaths string[] [] Paths that bypass auth (* wildcard supported)
roles Record<string, string[]> Path pattern → required roles
Redirect URI

The redirectUri is the full absolute URL that Discord redirects users to after authorization. It must match exactly what is registered in the Discord Developer Portal under OAuth2 → Redirects.

Resolution order (when redirectUri is not provided):

  1. config.redirectUri (explicit)
  2. process.env.DISCORD_REDIRECT_URI (environment variable)
  3. Auto-generated from the route prefix: {routes.prefix}/callback (e.g. /auth/discord/callback)

Setting DISCORD_REDIRECT_URI is useful when the auto-computed path doesn't match your deployment URL (e.g. reverse proxy, custom domain).

For the OAuth2 flow to work, you MUST:

  1. Add the full absolute callback URL to your Discord app at https://discord.com/developers/applications/{your-app-id}/oauth2
  2. Ensure it matches the redirectUri your application sends

Examples:

Environment Discord Portal Entry Config
Local dev http://localhost:3000/auth/discord/callback redirectUri: "http://localhost:3000/auth/discord/callback"
Production https://your-site.com/auth/discord/callback redirectUri: "https://your-site.com/auth/discord/callback"
discordAuth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
  redirectUri: process.env.DISCORD_REDIRECT_URI!,
})

The URL must match character-for-character including protocol (http vs https), trailing slashes, and port number. In production, always use https. Discord does not allow http except for localhost.


Elysia Plugin

discordAuth(config)

Registers OAuth2 login, callback, logout routes and exposes auth macros.

import { Elysia } from "elysia"
import { discordAuth } from "@hallaxius/auth/elysia"

const app = new Elysia()
  .use(discordAuth({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: {
      type: "jwt",
      secret: process.env.JWT_SECRET!,
      expiresIn: "7d",
    },
    scopes: ["identify", "guilds"],
    prompt: "consent",
  }))
Factory Pattern / Presets

New in v2.0.0 — Elysia plugin moved to @hallaxius/auth/elysia. Requires elysia >= 1.4.29 and @elysiajs/jwt installed separately.

discordAuth.from(config)

Explicit factory alias for discordAuth(). Use when you prefer a factory-style API.

import { discordAuth } from "@hallaxius/auth/elysia"

const app = discordAuth.from({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: { type: "jwt", secret: process.env.JWT_SECRET! },
})
discordAuth.presets.spa(opts), .server(opts), .nextjs(opts), .edge(opts)

Pre-configured presets with sensible defaults for each environment:

Preset session.type secure sameSite Best For
spa jwt false lax React / Vue / Svelte (localhost dev)
server server true lax Traditional backends (requires storage)
nextjs jwt true lax Next.js App Router / Middleware
edge jwt true lax Cloudflare Workers, Deno, edge runtimes
// SPA
discordAuth.presets.spa({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    secret: process.env.JWT_SECRET!,
})

// Server with storage
discordAuth.presets.server({
    clientId: "...",
    clientSecret: "...",
    secret: "...",
    storage: myStorage,
})
discordAuth.middlewares(deps)

Returns the same standalone middleware factory as middlewares(). An alias for when you want to access middlewares through the plugin object.

Type Inference

Use InferUser, InferSession, and InferStoredUser to extract the JWT payload type from your configured app:

import { Elysia } from "elysia"
import { discordAuth, type InferUser } from "@hallaxius/auth/elysia"

const app = new Elysia()
    .use(discordAuth({
        clientId: "...",
        clientSecret: "...",
        session: { type: "jwt", secret: process.env.JWT_SECRET! },
    }))

type User = InferUser<typeof app>
// User has: { discordId: string; username: string; ... }
Macros
Macro Type Description
auth boolean Requires authentication; injects ctx.user and ctx.storedUser
optionalAuth boolean Optional auth; injects ctx.user (null if not authenticated)
requireRole string[] Requires auth + specific roles (only with storage)
Routes Registered
Route Method Description
GET /auth/discord GET Redirects to Discord OAuth2 consent
GET /auth/discord/callback GET Handles OAuth2 callback, creates session
GET /auth/discord/logout GET Destroys session, clears cookie
GET /auth/me GET Returns current user (only when storage is configured)
Route Guards
// Protected route — injects ctx.user + ctx.storedUser
app.get("/dashboard", ({ user, storedUser }) => {
  return { user, storedUser }
}, { auth: true })

// Optional auth — user is null when not logged in
app.get("/", ({ user }) => {
  return user ? `Hello ${user.username}` : "Hello stranger"
}, { optionalAuth: true })

// Role-based — 403 if user lacks required role
app.get("/admin", ({ user }) => {
  return `Admin panel — ${user.username}`
}, { requireRole: ["admin"] })
Custom Routes
discordAuth({
  routes: {
    prefix: "/api/auth",
    callback: "/api/auth/callback",
    logout: "/api/auth/logout",
  },
})

Routes are used directly as absolute paths. The prefix controls the login route and the redirectUri (auto-computed as {prefix}/callback, or overridden by the explicit redirectUri option):

  • GET /api/auth → login
  • GET /api/auth/callback → callback
  • GET /api/auth/logout → logout
Callbacks
discordAuth({
  callbacks: {
    onSuccess: async (user, tokens) => {
      console.log(`User ${user.username} logged in`)
      return { redirect: "/welcome" }
    },
    onError: async (error, phase) => {
      console.error(`Error in ${phase}:`, error)
      return { redirect: "/error" }
    },
  },
})
User Persistence
import { createStorage } from "./my-storage"

discordAuth({
  storage: createStorage(db),
})

When storage is provided:

  • Users are persisted on first login and updated on return
  • /me route is registered
  • requireRole macro becomes available
  • ctx.storedUser contains SafeStoredUser (no tokens)
new Discord(config) — Class Wrapper

Wraps the Elysia plugin with a fluent API.

import { Discord } from "@hallaxius/auth"

const app = new Discord({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
  storage: myStorage,
})

// Route guards work the same way
app
  .get("/dashboard", ({ user }) => `Hello ${user.username}`, { auth: true })
  .get("/admin", ({ user }) => `Admin: ${user.username}`, { requireRole: ["admin"] })
  .use(somePlugin)
  .listen(3000, () => console.log("Server running"))

// Access the underlying Elysia instance
const rawElysia = app.raw
Available Methods
Method Returns Description
.get(path, handler, opts?) this Register GET route
.post(path, handler, opts?) this Register POST route
.put(path, handler, opts?) this Register PUT route
.delete(path, handler, opts?) this Register DELETE route
.patch(path, handler, opts?) this Register PATCH route
.all(path, handler, opts?) this Register route for all methods
.use(plugin) this Mount Elysia plugin
.onError(handler) this Register error handler
.listen(port, cb?) Elysia app Start server
.raw Elysia app Access raw Elysia instance
Properties
Property Type Description
.config DiscordAuthConfig Config passed to constructor
.storage UserStorage | null Configured storage adapter
.raw Elysia app Raw Elysia instance for advanced use

Standalone Mode

Works with Next.js App Router, Node.js, Bun, or any runtime that supports standard Request/Response.

auth(config) — Factory
import { auth } from "@hallaxius/auth"

const discord = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
  scopes: ["identify", "email"],
})

Returns:

Export Type Description
handleLogin (Request) => Promise<Response> Redirects to Discord OAuth2
handleCallback (Request) => Promise<Response> Exchanges code, sets cookie
handleLogout (Request) => Promise<Response> Clears session cookie (revokes token if storage present)
handleMe (Request) => Promise<Response> Returns current user (JSON)
withAuth (handler) => handler Protects a route handler
withOptionalAuth (handler) => handler Optional auth wrapper
withRole (...roles: string[]) => (handler: AuthHandler) => (Request) => Promise<Response> Role-based guard
Route Handlers
Login
// app/auth/discord/route.ts
import { auth } from "@hallaxius/auth"

const { handleLogin } = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})

export const GET = handleLogin
Callback
// app/auth/discord/callback/route.ts
import { auth } from "@hallaxius/auth"

const { handleCallback } = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})

export const GET = handleCallback
Logout
// app/auth/discord/logout/route.ts
import { auth } from "@hallaxius/auth"

const { handleLogout } = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})

export const GET = handleLogout
/me (Current User)
// app/api/me/route.ts
import { auth } from "@hallaxius/auth"

const { handleMe } = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})

export const GET = handleMe

With storage configured, returns SafeStoredUser (no tokens). Without storage, returns SessionData.

Route Guards
withAuth — Protected Handler
// app/api/profile/route.ts
import { auth } from "@hallaxius/auth"

const { withAuth } = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
})

export const GET = withAuth(async (req, { user, storedUser }) => {
  return Response.json({
    discordId: user.discordId,
    username: user.username,
    roles: storedUser?.roles ?? [],
  })
})

Returns 401 if not authenticated.

withOptionalAuth — Optional Auth
// app/api/posts/route.ts
import { withOptionalAuth } from "@/lib/auth"

export const GET = withOptionalAuth(async (req, { user, storedUser }) => {
  return Response.json({
    posts: await getPosts(),
    user: user ? { discordId: user.discordId } : null,
  })
})

Passes null for user when not authenticated (no error).

withRole — Role-based Guard
// app/api/admin/route.ts
import { withRole } from "@/lib/auth"

export const GET = withRole("admin", "moderator")(async (req, { user, storedUser }) => {
  return Response.json({ secret: "admin data" })
})

Returns 401 if not authenticated, 403 if missing required roles. Requires storage.

Shared Config (Best Practice)
// lib/auth.ts
import { auth } from "@hallaxius/auth"
import { myStorage } from "./storage"

export const discord = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: {
    type: "jwt",
    secret: process.env.JWT_SECRET!,
    expiresIn: "7d",
    secure: process.env.NODE_ENV === "production",
  },
  scopes: ["identify", "email", "guilds"],
  routes: {
    prefix: "/auth/discord",
    callback: "/callback",
  },
  callbacks: {
    onSuccess: async (user) => {
      console.log(`Login: ${user.username}`)
    },
  },
  storage: myStorage,
})

export const { handleLogin, handleCallback, handleLogout, handleMe, withAuth, withOptionalAuth, withRole } = discord

Edge / Next.js Middleware

Functions that intercept requests before they reach route handlers. Perfect for protecting groups of routes in Next.js middleware.ts or any edge runtime.

Edge Middleware (Generic)

Works with standard Request/Response (Bun, Node, CF Workers, Deno).

middlewareAuth(config)
import { middlewareAuth } from "@hallaxius/auth"

const auth = middlewareAuth({
  secret: process.env.JWT_SECRET!,
  loginUrl: "/auth/discord",
  publicPaths: ["/", "/auth/*", "/api/public/*"],
})

Returns: (Request) => Promise<Response | undefined>

Return Status Meaning
Response 302 Redirect to login
undefined Allow request through
middlewareRole(config)
import { middlewareRole } from "@hallaxius/auth"

const guard = middlewareRole({
  secret: process.env.JWT_SECRET!,
  loginUrl: "/auth/discord",
  roles: {
    "/admin/*": ["admin"],
    "/moderator/*": ["admin", "moderator"],
  },
})

Returns: (Request) => Promise<Response | undefined>

Return Status Meaning
Response 302 Redirect to login
Response 403 Forbidden (JSON: { error: "Forbidden" })
undefined Allow request through

Important: Roles must be embedded in the JWT token. This happens automatically when storage is configured and the user logs in through our callback.

combine(...middlewares)
import { combine, middlewareAuth, middlewareRole } from "@hallaxius/auth"

export default combine(
  middlewareAuth({ secret: "...", publicPaths: ["/"] }),
  middlewareRole({ secret: "...", roles: { "/admin/*": ["admin"] } }),
)

Executes middlewares in order; stops at the first that returns a Response. If all return undefined, request proceeds.

Path Pattern Matching

Patterns support * wildcard:

Pattern Matches
/auth/* /auth, /auth/login, /auth/discord/callback
/admin/* /admin, /admin/users, /admin/settings
/api/public/* /api/public, /api/public/data
/dashboard /dashboard (exact match only)
Next.js Adapter

For middleware.ts running on Edge Runtime.

nextAuth(config)
// middleware.ts
import { nextAuth } from "@hallaxius/auth"

export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*"],
}

export default nextAuth({
  secret: process.env.JWT_SECRET!,
  loginUrl: "/auth/discord",
  publicPaths: ["/", "/auth/*"],
})
nextRole(config)
export default nextRole({
  secret: process.env.JWT_SECRET!,
  loginUrl: "/auth/discord",
  roles: {
    "/admin/*": ["admin"],
  },
})
Full Example
// middleware.ts
import {
  nextAuth,
  nextRole,
  combine,
} from "@hallaxius/auth"

export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*", "/api/protected/:path*"],
}

export default combine(
  nextAuth({
    secret: process.env.JWT_SECRET!,
    loginUrl: "/auth/discord",
    publicPaths: [
      "/",
      "/auth/*",
      "/api/public/*",
      "/_next/*",
      "/favicon.ico",
    ],
  }),
  nextRole({
    secret: process.env.JWT_SECRET!,
    loginUrl: "/auth/discord",
    roles: {
      "/admin/*": ["admin"],
      "/api/admin/*": ["admin"],
    },
  }),
)
Utilities
getSession(request, config)

Extracts session from any Request. Works in middleware, route handlers, or server components.

import { getSession } from "@hallaxius/auth"

// middleware.ts
const user = await getSession(request, {
  secret: process.env.JWT_SECRET!,
  cookieName: "discord-auth-session",
})

if (user) {
  console.log(user.discordId, user.username, user.roles)
}

Returns SessionData | null.

isPublicPath(path, patterns)
import { isPublicPath } from "@hallaxius/auth"

isPublicPath("/auth/login", ["/auth/*"])   // true
isPublicPath("/dashboard", ["/auth/*"])     // false
requiredRole(path, roleMap)
import { requiredRole } from "@hallaxius/auth"

requiredRole("/admin/users", { "/admin/*": ["admin"] })  // ["admin"]
requiredRole("/dashboard", { "/admin/*": ["admin"] })     // null
redirect(url)

Creates a 302 Response with the given Location header. Only relative URLs are allowed (must start with /).

return redirect("/auth/discord")
// Response { status: 302, headers: { Location: "/auth/discord" } }
denied(message?)

Creates a 403 Response with JSON body.

return denied("Access denied")
// Response { status: 403, body: { error: "Access denied" } }

User Persistence

When you provide a storage implementation, users are persisted to your database and roles are enabled.

The UserStorage Interface
interface UserStorage {
  findByDiscordId(discordId: string): Promise<StoredUser | null>
  create(data: CreateUserData): Promise<StoredUser>
  update(discordId: string, data: Partial<CreateUserData>): Promise<StoredUser>
  delete(discordId: string): Promise<void>
}
StoredUser
interface StoredUser {
  id: string              // Your DB primary key
  discordId: string       // Discord user ID
  username: string
  globalName: string | null
  avatar: string | null
  email: string | null
  locale: string
  roles: string[]
  accessToken: string     // Discord access token
  refreshToken: string    // Discord refresh token
  tokenExpiresAt: number  // Unix timestamp
  createdAt: Date
  updatedAt: Date
}
SafeStoredUser
type SafeStoredUser = Omit<StoredUser, "accessToken" | "refreshToken">

Used by /me endpoint and route guards. Never exposes tokens to the client.

CreateUserData
interface CreateUserData {
  discordId: string
  username: string
  globalName: string | null
  avatar: string | null
  email: string | null
  locale: string
  roles: string[]
  accessToken: string
  refreshToken: string
  tokenExpiresAt: number
}
How It Works
  1. First loginstorage.create(data) is called with default role ["user"]
  2. Returning userstorage.update(discordId, data) refreshes tokens + profile
  3. Roles → embedded in JWT at login; checked by requireRole / withRole
  4. /me endpoint → returns SafeStoredUser (accessToken/refreshToken excluded)
  5. Route guardsctx.storedUser contains SafeStoredUser | null
Example: PostgreSQL with Drizzle
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core"
import { eq } from "drizzle-orm"
import type { CreateUserData, StoredUser, UserStorage } from "@hallaxius/auth"

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  discordId: text("discord_id").unique().notNull(),
  username: text("username").notNull(),
  globalName: text("global_name"),
  avatar: text("avatar"),
  email: text("email"),
  locale: text("locale").notNull().default("en"),
  roles: text("roles").array().notNull().default(["{user}"]),
  accessToken: text("access_token").notNull(),
  refreshToken: text("refresh_token").notNull(),
  tokenExpiresAt: integer("token_expires_at").notNull(),
  createdAt: timestamp("created_at").defaultNow(),
  updatedAt: timestamp("updated_at").defaultNow(),
})

export function createDrizzleStorage(db: DrizzleClient): UserStorage {
  return {
    async findByDiscordId(discordId) {
      const result = await db
        .select()
        .from(users)
        .where(eq(users.discordId, discordId))
        .limit(1)
      return (result[0] as StoredUser) ?? null
    },

    async create(data) {
      const id = crypto.randomUUID()
      const now = new Date()
      await db.insert(users).values({ ...data, id, createdAt: now, updatedAt: now })
      return { ...data, id, createdAt: now, updatedAt: now }
    },

    async update(discordId, data) {
      const updated = await db
        .update(users)
        .set({ ...data, updatedAt: new Date() })
        .where(eq(users.discordId, discordId))
        .returning()
      return updated[0] as StoredUser
    },

    async delete(discordId) {
      await db.delete(users).where(eq(users.discordId, discordId))
    },
  }
}
Example: PostgreSQL with Prisma
import { PrismaClient } from "@prisma/client"
import type { CreateUserData, StoredUser, UserStorage } from "@hallaxius/auth"

const prisma = new PrismaClient()

export const prismaStorage: UserStorage = {
  async findByDiscordId(discordId) {
    const user = await prisma.user.findUnique({ where: { discordId } })
    return user as StoredUser | null
  },

  async create(data) {
    const user = await prisma.user.create({
      data: {
        ...data,
        id: crypto.randomUUID(),
        roles: data.roles ?? ["user"],
      },
    })
    return user as StoredUser
  },

  async update(discordId, data) {
    const user = await prisma.user.update({
      where: { discordId },
      data: { ...data, updatedAt: new Date() },
    })
    return user as StoredUser
  },

  async delete(discordId) {
    await prisma.user.delete({ where: { discordId } })
  },
}
Example: In-Memory (Dev Only)
import type { CreateUserData, StoredUser, UserStorage } from "@hallaxius/auth"

const db = new Map<string, StoredUser>()

export const memoryStorage: UserStorage = {
  async findByDiscordId(discordId) {
    for (const user of db.values()) {
      if (user.discordId === discordId) return user
    }
    return null
  },

  async create(data) {
    const now = new Date()
    const user: StoredUser = {
      ...data,
      id: crypto.randomUUID(),
      createdAt: now,
      updatedAt: now,
    }
    db.set(user.id, user)
    return user
  },

  async update(discordId, data) {
    const existing = await this.findByDiscordId(discordId)
    if (!existing) throw new Error("User not found")
    const updated = { ...existing, ...data, updatedAt: new Date() }
    db.set(updated.id, updated)
    return updated
  },

  async delete(discordId) {
    for (const [id, user] of db) {
      if (user.discordId === discordId) {
        db.delete(id)
        return
      }
    }
  },
}
Behavior Without Storage

If storage is not provided:

Feature Available?
Login / Callback / Logout Always
/me route Not registered
requireRole macro Not available (returns 500 if used)
withRole guard Always exported (returns 500 if used without storage)
ctx.storedUser Always null
Roles in JWT Not embedded
Token auto-refresh Not available

Auto-Join Guild

Automatically add authenticated users to your Discord server using the DiscordClient.addMember() method.

Prerequisites
  1. Bot in the server — Create an app at https://discord.com/developers/applications, go to the Bot page, click "Reset Token", and copy the generated token.
  2. Invite the bot — Use the OAuth2 > URL Generator tab, select the bot scope and the Create Instant Invite permission, then use the generated URL to add the bot to your server.
  3. Scope guilds.join — Add "guilds.join" to your OAuth2 scopes config.
Basic Flow
Elysia Plugin
import { Elysia } from "elysia"
import { discordAuth, DiscordClient } from "@hallaxius/auth/elysia"

const GUILD_ID = "123456789"
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN!

const app = new Elysia()
  .use(discordAuth({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: { type: "jwt", secret: process.env.JWT_SECRET! },
    scopes: ["identify", "email", "guilds.join"],
    callbacks: {
      onSuccess: async (user, tokens) => {
        const client = new DiscordClient(
          process.env.DISCORD_CLIENT_ID!,
          process.env.DISCORD_CLIENT_SECRET!,
        )
        await client.addMember({
          guildId: GUILD_ID,
          userId: user.id,
          accessToken: tokens.access_token,
          botToken: BOT_TOKEN,
          nick: user.username,
        })
      },
    },
  }))
  .listen(3000)
Standalone (Next.js / Node)
// lib/auth.ts
import { auth, DiscordClient } from "@hallaxius/auth"

export const discord = auth({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
  session: { type: "jwt", secret: process.env.JWT_SECRET! },
  scopes: ["identify", "email", "guilds.join"],
  callbacks: {
    onSuccess: async (user, tokens) => {
      const client = new DiscordClient(
        process.env.DISCORD_CLIENT_ID!,
        process.env.DISCORD_CLIENT_SECRET!,
      )
      await client.addMember({
        guildId: process.env.DISCORD_GUILD_ID!,
        userId: user.id,
        accessToken: tokens.access_token,
        botToken: process.env.DISCORD_BOT_TOKEN!,
      })
    },
  },
})

export const { handleLogin, handleCallback } = discord
// app/auth/discord/callback/route.ts
import { handleCallback } from "@/lib/auth"
export const GET = handleCallback
Method API
DiscordClient.addMember(params)

Adds an authenticated user to the server using the Add Guild Member API.

Parameters (AddMemberParams)
Field Type Required Description
guildId string Discord server ID
userId string User ID (from user.id)
accessToken string OAuth2 access token (tokens.access_token)
botToken string Discord bot token (from Bot page in Dev Portal)
nick string User's nickname on the server
roles string[] Role IDs to assign to the member
Returns

Promise<void> — resolves with no value on success, rejects with Error if the API returns an error.

Possible Errors
HTTP Status Likely Cause Solution
201 Member created successfully Success
204 Member already in the server Success
400 Invalid parameters or expired token Verify access_token is valid and guilds.join scope is present
403 Bot lacks permission Verify bot is in the server and has CREATE_INSTANT_INVITE permission
404 Invalid guildId or userId Verify the IDs
429 Rate limit reached Wait and retry
DiscordClient.getGuildMember(guildId, userId, botToken)

Fetches a guild member's profile using the Get Guild Member API. The bot must be in the guild.

import { DiscordClient } from "@hallaxius/auth"

const client = new DiscordClient(process.env.DISCORD_CLIENT_ID!, process.env.DISCORD_CLIENT_SECRET!)
const member = await client.getGuildMember("guild-id", "user-id", process.env.DISCORD_BOT_TOKEN!)
console.log(member.nick, member.roles)
Parameters
Field Type Required Description
guildId string Discord server ID
userId string User's Discord ID
botToken string Discord bot token (from Bot page in Dev Portal)
Returns

Promise<DiscordGuildMember> — the guild member object with user, nick, roles, joined_at, premium_since, deaf, mute, pending fields.

Pattern with UserStorage

If you use storage, you can call addMember inside a custom hook after the callback:

callbacks: {
  onSuccess: async (user, tokens) => {
    // 1. User is already persisted automatically with storage
    // 2. Add to server
    const client = new DiscordClient(
      process.env.DISCORD_CLIENT_ID!,
      process.env.DISCORD_CLIENT_SECRET!,
    )
    await client.addMember({
      guildId: process.env.DISCORD_GUILD_ID!,
      userId: user.id,
      accessToken: tokens.access_token,
      botToken: process.env.DISCORD_BOT_TOKEN!,
    })
  },
},
Important Notes
  • Bot token ≠ Client Secret — the botToken comes from the Bot page in the Dev Portal, not the Client Secret from the OAuth2 page. These are different.
  • Scope guilds.join is required — without it, the access_token cannot join the server.
  • The bot must be in the server before calling addMember. Use the URL Generator with the bot scope to invite it.
  • Rate limits — the members API is limited to 1 request per second per guild. For bulk joins, consider using a queue with delay.

Security Features

New in v2.0.0 — six advanced security controls for production-grade deployments.

CSRF Protection
interface CsrfConfig {
  enabled?: boolean = true;        // Enables CSRF protection
  ttlMs?: number = 5 * 60 * 1000;  // State token TTL (5 minutes)
  singleUse?: boolean = true;      // Tokens can only be used once
  bindToSession?: boolean = true;  // Binds state to session ID
  bindToUserAgent?: boolean = true;// Binds state to User-Agent
}

State tokens carry HMAC-signed payloads (crypto.subtle.sign) containing:

  • Random UUID generation for uniqueness
  • Timestamp for expiry validation
  • Optional: codeVerifier (PKCE), sessionId, userAgentHash (bound)

Tokens are validated on callback — fails if:

  • Signature mismatch (forgery attempt)
  • Token expired (TTL exceeded)
  • Token reused (singleUse prevents replay attacks)
  • Session/User-Agent mismatch (binding validation)
discordAuth({
  csrf: {
    enabled: true,
    ttlMs: 5 * 60 * 1000, // 5 minutes
    singleUse: true,
  },
})

Brute Force Protection
interface BruteForceConfig {
  enabled?: boolean = true;              // Enables brute force protection
  maxAttempts?: number = 5;              // Max attempts allowed in window
  windowMs?: number = 15 * 60 * 1000;    // Sliding window (15 minutes)
  blockDurationMs?: number = 30 * 60 * 1000; // Block duration (30 minutes)
  storage?: BruteForceStorage;           // Custom storage adapter (Redis for production)
}

IP-based rate limiting prevents automated attacks:

  • Tracks attempts per (IP + User-Agent) key
  • Increments on failure, resets on success
  • Blocks key for blockDurationMs when maxAttempts exceeded
discordAuth({
  bruteForce: {
    enabled: true,
    maxAttempts: 10,
    blockDurationMs: 10 * 60 * 1000, // 10 minute block
  },
})

MFA Enforcement
interface MfaConfig {
  enabled?: boolean = false;     // Enables MFA checks
  requireMfa?: boolean = false;  // Rejects users without MFA
}

Blocks OAuth2 callback with 403 Forbidden if Discord user lacks 2FA:

  • Checks mfa_enabled flag in Discord user object (/users/@me)
  • Throws MfaRequiredError for consistent error handling
  • Protects high-security applications against credential theft
discordAuth({
  mfa: {
    enabled: process.env.NODE_ENV === "production",
    requireMfa: true, // Only allow users with MFA
  },
})

Token Auto-Refresh
interface AutoRefreshConfig {
  enabled?: boolean = true;      // Enables auto-refresh
  thresholdSeconds?: number = 300; // Refresh if < 5min remaining
  maxRetries?: number = 1;       // Max refresh attempts
}

Silent preemptive token refresh within configured window:

  • Checks if token expires within thresholdSeconds
  • Uses refresh_token to fetch new tokens via Discord's /oauth2/token
  • Updates stored user data silently — no re-authentication needed
discordAuth({
  autoRefresh: {
    enabled: true,
    thresholdSeconds: 300, // Refresh if < 5min left
  },
})

Guild Role Sync
interface GuildRoleSyncConfig {
  enabled?: boolean = false;       // Enables role sync
  guildId: string;                 // Target Discord guild ID
  roleMap: Record<string, string[]>; // Maps Discord roles to app permissions
  cacheTtlMs?: number = 3600000;   // Cache TTL (1 hour)
  syncOnLogin?: boolean = false;   // Sync roles during login
  botToken: string;                // Discord bot token
}

Automatic role mapping from Discord guilds to app permissions:

  • Fetches user roles via Discord's /guilds/{guildId}/members/{userId}
  • Maps Discord roles to application-specific permissions via roleMap
  • Caches results to respect Discord rate limits
discordAuth({
  guildRoleSync: {
    enabled: true,
    guildId: process.env.DISCORD_GUILD_ID!,
    botToken: process.env.DISCORD_BOT_TOKEN!,
    roleMap: { "admin-role-id": ["admin"] },
    syncOnLogin: true,
  },
})

Type-safe Routes
type TypedCallbackQuery = CallbackQuery & {
  error?: OAuth2ErrorCode; // Strongly typed OAuth2 error codes
};

// Extracts scopes from config at compile time
type InferScopes<Config extends DiscordAuthConfig> =
  Config["scopes"] extends readonly DiscordScope[]
    ? Config["scopes"]
    : DiscordScope[];

Generic route helpers with compile-time scope inference:

  • Wraps raw CallbackQuery with typed error field
  • Infers scopes from config at compile time
  • Type-safe route handlers prevent runtime errors
import { createTypedRouteHandlers } from "@hallaxius/auth/elysia"

const handlers = createTypedRouteHandlers<MyConfig>()({
  callback: async (query, ctx) => {
    query.error; // Type-checked OAuth2 error code
    ctx.scopes;  // Inferred from MyConfig
  },
})

Utility Helpers

generateSecureSecret(length?)

Generates a cryptographically secure, URL-safe random string using the Web Crypto API.

import { generateSecureSecret } from "@hallaxius/auth"

const secret = generateSecureSecret(32) // e.g. "xK8...base64url..."
Param Type Default Description
length number 32 Number of random bytes (output is base64url encoded, slightly longer)

validateConfig(config)

Validates DiscordAuthConfig for common errors — missing or malformed fields. Throws ConfigurationError with a descriptive message for any invalid field.

import { validateConfig, ConfigurationError } from "@hallaxius/auth"

try {
  validateConfig({ clientId: "", clientSecret: "" })
} catch (e) {
  if (e instanceof ConfigurationError) {
    console.error(e.message) // "Missing required configuration: 'clientId' is required..."
  }
}

hasRoleInGuild(userId, guildId, roleId, botToken, clientId, clientSecret)

Checks whether a Discord user has a specific role in a guild. Uses the Discord Bot API. Returns false on any error (never throws).

import { hasRoleInGuild } from "@hallaxius/auth"

const isAdmin = await hasRoleInGuild(
  "discord-user-id",
  "guild-id",
  "admin-role-id",
  process.env.DISCORD_BOT_TOKEN!,
  process.env.DISCORD_CLIENT_ID!,
  process.env.DISCORD_CLIENT_SECRET!,
)

hasAnyRoleInGuild(userId, guildId, roleIds, botToken, clientId, clientSecret)

Checks whether a Discord user has at least one of the given roles in a guild. Returns false on any error.

import { hasAnyRoleInGuild } from "@hallaxius/auth"

const isStaff = await hasAnyRoleInGuild(
  "user-id", "guild-id",
  ["admin-role", "mod-role"],
  botToken, clientId, clientSecret,
)

isUserInGuild(userId, guildId, botToken, clientId, clientSecret)

Returns true if the user is a member of the specified guild, false otherwise.

import { isUserInGuild } from "@hallaxius/auth"

const isMember = await isUserInGuild("user-id", "guild-id", botToken, clientId, clientSecret)

revokeUserSession(discordId, storage, clientId, clientSecret)

Deletes a user's session from storage and revokes their Discord access token. Throws StorageError if storage operations fail.

import { revokeUserSession } from "@hallaxius/auth"

await revokeUserSession("discord-id", myStorage, clientId, clientSecret)

syncUserRoles(discordId, guildId, botToken, storage, clientId, clientSecret)

Fetches the latest Discord guild roles for a user and updates their stored roles. Returns the updated roles array.

import { syncUserRoles } from "@hallaxius/auth"

const roles = await syncUserRoles("discord-id", "guild-id", botToken, myStorage, clientId, clientSecret)

autoJoinGuild(params)

Adds a user to a Discord guild after authentication. Requires guilds.join scope and a bot token. Throws GuildJoinError on failure.

import { autoJoinGuild } from "@hallaxius/auth"

await autoJoinGuild({
  guildId: "guild-id",
  userId: "user-id",
  accessToken: "user-oauth-token",
  botToken: process.env.DISCORD_BOT_TOKEN!,
  nick: "New Member",
  roles: ["role-id"],
  clientId: "client-id",
  clientSecret: "client-secret",
})
Field Type Required Description
guildId string Discord server ID
userId string User's Discord ID
accessToken string OAuth2 access token
botToken string Discord bot token
nick string Nickname for the member
roles string[] Role IDs to assign
clientId string Discord OAuth2 Client ID
clientSecret string Discord OAuth2 Client Secret

API Reference

Functions
Function Returns Description
auth(config) { handleLogin, handleCallback, handleLogout, handleMe, withAuth, withOptionalAuth, withRole } Standalone factory
autoJoinGuild(params) Promise<void> Adds a user to a Discord guild
combine(...middlewares) MiddlewareFn Composes multiple edge middlewares into one
denied(message?) Response Creates a 403 Response with JSON body { error: message }
discordAuth(config) Elysia instance Creates an Elysia plugin (from @hallaxius/auth/elysia)
discordAuth.from(config) Elysia instance Factory alias for discordAuth() (from @hallaxius/auth/elysia)
discordAuth.middlewares(deps) { withAuth, withOptionalAuth, withRole } Standalone middleware factory alias (from @hallaxius/auth/elysia)
discordAuth.presets.spa(opts) Elysia instance Pre-configured SPA preset (from @hallaxius/auth/elysia)
discordAuth.presets.server(opts) Elysia instance Pre-configured server preset (from @hallaxius/auth/elysia)
discordAuth.presets.nextjs(opts) Elysia instance Pre-configured Next.js preset (from @hallaxius/auth/elysia)
discordAuth.presets.edge(opts) Elysia instance Pre-configured edge preset (from @hallaxius/auth/elysia)
generateSecureSecret(length?) string Generates a crypto-random URL-safe string
getSession(request, config) Promise<SessionData | null> Extracts and verifies a session from a Request's cookie
hasRoleInGuild(userId, guildId, roleId, botToken, clientId, clientSecret) Promise<boolean> Checks if a user has a specific Discord role
hasAnyRoleInGuild(userId, guildId, roleIds, botToken, clientId, clientSecret) Promise<boolean> Checks if a user has any of the given roles
isPublicPath(path, patterns) boolean Checks if a path matches any of the public path patterns
isUserInGuild(userId, guildId, botToken, clientId, clientSecret) Promise<boolean> Checks if a user is in a guild
middlewareAuth(config) (Request) => Promise<Response | undefined> Creates an edge auth middleware
middlewareRole(config) (Request) => Promise<Response | undefined> Creates an edge role middleware
nextAuth(config) (Request) => Promise<Response | undefined> Creates a Next.js-compatible auth middleware
nextRole(config) (Request) => Promise<Response | undefined> Creates a Next.js-compatible role middleware
redirect(url) Response Creates a 302 Response with the given Location header (relative URLs only)
requiredRole(path, roleMap) string[] | null Returns the required roles for a path pattern
revokeUserSession(discordId, storage, clientId, clientSecret) Promise<void> Deletes user session and revokes token
syncUserRoles(discordId, guildId, botToken, storage, clientId, clientSecret) Promise<string[]> Syncs guild roles to storage
validateConfig(config) void Validates DiscordAuthConfig, throws ConfigurationError
Classes
Discord

Elysia wrapper class with fluent API. See Elysia Plugin — Class Wrapper.

DiscordClient

Discord API client with methods for OAuth2, user info, and guild management.

Methods
Method Description
generateAuthUrl(params) Generates Discord OAuth2 authorization URL
exchangeCode(params) Exchanges authorization code for tokens
refreshToken(params) Refreshes an expired access token
revokeToken(params) Revokes an access token
getUser(accessToken) Returns the authenticated user's profile
getUserGuilds(accessToken) Returns the user's guilds
getUserConnections(accessToken) Returns the user's connected accounts
getGuildMember(guildId, userId, botToken) Gets a guild member's profile (requires bot in guild)
addMember(params) Adds a user to a guild (requires guilds.join scope + bot token)
Types
Type Definition Description
AddMemberParams { guildId, userId, accessToken, botToken, nick?, roles? } Parameters for client.addMember()
AuthHandler (Request, { user: SessionData | null, storedUser: SafeStoredUser | null }) => Response | Promise<Response> Route handler with auth context
Callbacks { onSuccess?, onError? } Auth lifecycle hooks
CreateUserData { discordId, username, ..., roles, accessToken, refreshToken, tokenExpiresAt } Data for user creation
DiscordAuthConfig { clientId, clientSecret, session, scopes?, prompt?, routes?, callbacks?, storage?, meRoute?, redirectUri?, disablePKCE? } Main config interface
DiscordConnection Discord connection shape Discord connection object
DiscordGuild Discord guild shape Discord guild object
DiscordGuildMember { user, nick, roles, joined_at, ... } Guild member object from Discord API
DiscordScope Union of all Discord OAuth2 scopes e.g. "identify", "email", "guilds"
DiscordTokenResponse Token response shape OAuth2 token response
DiscordUser Discord user shape (snake_case fields, e.g. global_name) Discord user object (raw API response)
EdgeAuthConfig Extends EdgeSessionConfig with loginUrl?, publicPaths? Config for auth middleware
EdgePresetOpts { clientId, clientSecret, secret, redirectUri?, scopes?, prompt? } Options for .presets.edge() (from @hallaxius/auth/elysia)
EdgeRoleConfig Extends EdgeSessionConfig with loginUrl?, roles Config for role middleware
GetGuildMemberParams { guildId, userId, botToken } Parameters for getting a guild member
GuildMember { user, nick, roles, joinedAt, ... } Guild member object (camelCase)
InferSession<T> Extracted JWT payload type Infers session type from Elysia instance (from @hallaxius/auth/elysia)
InferUser<T> Alias for InferSession<T> Infers user type from Elysia instance (from @hallaxius/auth/elysia)
InferStoredUser<T> Omit<InferSession<T>, "accessToken" | "refreshToken"> Safe stored user type (from @hallaxius/auth/elysia)
NextjsPresetOpts { clientId, clientSecret, secret, redirectUri?, scopes?, prompt? } Options for .presets.nextjs() (from @hallaxius/auth/elysia)
PromptType "consent" | "none" OAuth2 prompt type
RoutesConfig { prefix?, callback?, logout?, error? } Custom route paths
SafeStoredUser Omit<StoredUser, "accessToken" | "refreshToken"> StoredUser without sensitive fields
ServerPresetOpts { clientId, clientSecret, secret, storage, redirectUri?, scopes?, prompt? } Options for .presets.server() (from @hallaxius/auth/elysia)
SpaPresetOpts { clientId, clientSecret, secret, redirectUri?, scopes?, prompt? } Options for .presets.spa() (from @hallaxius/auth/elysia)
SessionConfig { type, secret, expiresIn?, cookieName?, cookiePath?, httpOnly?, secure?, sameSite? } Session configuration
SessionData { discordId, username, globalName, avatar, email, locale, roles? } Session payload
SessionType "jwt" | "server" Session storage type
StoredUser Full user shape with tokens Persisted user object
UserStorage { findByDiscordId, create, update, delete } Persistence interface

Security Best Practices

This library implements OAuth 2.0 best practices by default. Follow these guidelines to keep your application secure.

New in v2.0.0: Six additional security controls — see Security Features for CSRF, brute force, MFA, token auto-refresh, guild role sync, and type-safe routes.

Always Enabled (No Configuration Required)
Feature Implementation Protection Against
PKCE (S256) code_verifier + code_challenge in auth flow Authorization code interception
State Parameter HMAC-SHA256 signed state with 5-minute TTL CSRF attacks
Token Revocation Automatic revokeToken() on logout Token reuse after logout
Rate Limit Detection Monitors Retry-After, X-RateLimit-* headers API abuse, DoS
Configuration Checklist
1. HTTPS in Production
// Always use HTTPS in production
session: {
    type: "jwt",
    secret: process.env.JWT_SECRET!,
    secure: process.env.NODE_ENV === "production", // Required for HTTPS
    sameSite: "lax", // or "strict" for more security
}

Discord requires HTTPS for OAuth2 callbacks. secure: true ensures cookies are only sent over HTTPS.

2. Secrets Management
# Never hardcode secrets in your code
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
JWT_SECRET=generate_a_strong_secret_here
DISCORD_BOT_TOKEN=your_bot_token

JWT Secret Requirements:

  • Minimum 32 characters (256 bits)
  • Use cryptographically random string: crypto.randomUUID() + crypto.randomUUID()
  • Never reuse Discord client secret as JWT secret
3. Redirect URI Configuration

The redirectUri must match exactly what's registered in the Discord Developer Portal:

discordAuth({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: { type: "jwt", secret: process.env.JWT_SECRET! },
    redirectUri: "https://yourdomain.com/auth/discord/callback", // Must match Discord Portal
})

Common Mistakes:

  • http:// in production (Discord blocks this)
  • Missing trailing slash
  • Different port number
  • localhost in production

Check your redirect URI at: https://discord.com/developers/applications/{app-id}/oauth2

4. Scopes

Only request the scopes you need:

// Good: Minimal scopes
scopes: ["identify"]

// Only if you need email
scopes: ["identify", "email"]

// Only if you need guild info
scopes: ["identify", "guilds"]

// Only if you need to add users to your server
scopes: ["identify", "email", "guilds.join"]

Fewer scopes = smaller consent dialog = higher conversion rate.

5. Session Security
session: {
    type: "jwt",
    secret: process.env.JWT_SECRET!,
    expiresIn: "7d", // Or "1h", "24h" - adjust to your needs
    secure: process.env.NODE_ENV === "production",
    httpOnly: true, // Always true - prevents XSS
    sameSite: "lax", // or "strict" for more security
    cookieName: "discord-auth-session",
}
Option Recommended Why
httpOnly true Prevents JavaScript from reading cookies (XSS protection)
secure true (production) Ensures cookies are only sent over HTTPS
sameSite "lax" or "strict" Prevents CSRF attacks
expiresIn "7d" or less Limits session lifetime
PKCE (Proof Key for Code Exchange)

PKCE is enabled by default for all applications. This protects against authorization code interception attacks, which is especially important for:

  • Single Page Applications (SPAs)
  • Mobile applications
  • Any public client where the client secret cannot be kept confidential

How it works:

  1. A random code_verifier is generated for each login request
  2. The code_challenge (SHA-256 hash of the verifier) is sent to Discord
  3. When Discord redirects back, the code_verifier is used to exchange the code for tokens
  4. Without the correct verifier, the code cannot be exchanged

Disabling PKCE (Not Recommended):

discordAuth({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: { type: "jwt", secret: process.env.JWT_SECRET! },
    disablePKCE: true, // Only disable for server-side confidential clients
})

Only disable PKCE if you fully understand the security implications and are using a server-side confidential client.

Rate Limiting

The library detects Discord API rate limits and throws RateLimitError:

import { RateLimitError } from "@hallaxius/auth"

try {
    await client.getUser(accessToken)
} catch (error) {
    if (error instanceof RateLimitError) {
        // error.retryAfter contains seconds to wait
        console.log(`Rate limited. Retry after ${error.retryAfter} seconds`)
        // Implement your retry logic with exponential backoff
    }
}

Discord Rate Limit Headers:

  • X-RateLimit-Remaining: Requests remaining in current window
  • X-RateLimit-Reset: Unix timestamp when rate limit resets
  • Retry-After: Seconds to wait (when rate limited)
  • X-RateLimit-Global: Whether you're hitting the global rate limit
Security Checklist
  • All secrets stored in environment variables
  • secure: true for cookies in production
  • httpOnly: true for all session cookies
  • HTTPS enabled in production
  • Redirect URI matches Discord Developer Portal exactly
  • PKCE enabled (default)
  • Minimal scopes requested
  • Session expiration configured
  • Rate limit errors handled gracefully
  • Token revocation on logout enabled (default with storage)

Migration Guide

v1.x → v2.0.0 (Breaking Changes)

This major version introduces important architecture and security improvements.

Breaking Changes
1. Elysia Plugin Moved to Sub-path

Before (v1.x):

import { discordAuth } from "@hallaxius/auth"

After (v2.0.0):

import { discordAuth } from "@hallaxius/auth/elysia"

Requires elysia >= 1.4.29 and @elysiajs/jwt installed separately.

2. Peer Dependencies Removed

elysia and @elysiajs/jwt are no longer included as dependencies.

  • If you use the Elysia plugin: bun add elysia @elysiajs/jwt
  • If you use standalone mode: no changes needed
3. New Security Features (v2.0.0)
Feature Description
CSRF Protection HMAC-signed state tokens with single-use + session/user-agent binding
Brute Force Protection IP-based rate limiting with configurable thresholds and blocking
MFA Enforcement Block login if Discord user lacks 2FA
Token Auto-Refresh Silent refresh within configurable threshold window
Guild Role Sync Sync Discord guild roles to application permissions
Type-safe Routes Generic route handlers with compile-time scope inference

See Security Features for full documentation.


v0.x → v1.0.0 (Breaking Changes)

This major version introduces important security improvements. Most changes are backward compatible, but there are a few breaking changes.

Breaking Changes
1. PKCE is Now Enabled by Default

Before (v0.x):

// PKCE was not implemented
generateAuthUrl(params) // No code_challenge

After (v1.0.0):

// PKCE is enabled by default
generateAuthUrl(params) // Includes code_challenge + code_challenge_method=S256

If you need to disable PKCE (not recommended):

discordAuth({
    clientId: process.env.DISCORD_CLIENT_ID!,
    clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    session: { type: "jwt", secret: process.env.JWT_SECRET! },
    disablePKCE: true, // Only for server-side confidential clients
})

Impact: None for most users. PKCE is transparent and improves security. Only affects you if you're doing custom OAuth2 flow manipulation.

2. Error Hierarchy

Before (v0.x):

try {
    await client.exchangeCode(params)
} catch (error) {
    // error is a generic Error
    if (error.message.includes("Failed to exchange code")) {
        // handle error
    }
}

After (v1.0.0):

import {
    InvalidCodeError,
    TokenExpiredError,
    RateLimitError,
    NetworkError,
} from "@hallaxius/auth"

try {
    await client.exchangeCode(params)
} catch (error) {
    if (error instanceof InvalidCodeError) {
        // Handle invalid authorization code
    } else if (error instanceof RateLimitError) {
        // Handle rate limiting with error.retryAfter
    } else if (error instanceof NetworkError) {
        // Handle network errors
    }
}

Impact: Your existing catch blocks will still work

Keywords