@hallaxius/auth
Plug-and-play Discord OAuth2 authentication for Bun, Next.js, and any Node/edge runtime.
Features
- Discord OAuth2 — Authorization code flow with CSRF protection (Web Crypto HMAC)
- Elysia plugin —
discordAuth(config)with macrosauth,optionalAuth,requireRole - Standalone mode — For Next.js App Router, Node.js, or any edge runtime
- Route guards —
withAuth,withOptionalAuth,withRole(roles)for route handlers - Edge/Next.js middleware —
middlewareAuth,middlewareRole,combine,nextAuth,nextRole - User persistence — Pluggable
UserStorageinterface (implement for your DB) - JWT sessions — Stateless, edge-compatible (uses
jose) - Server sessions — In-memory Map with TTL (dev/light use)
- Auto-join guild —
DiscordClient.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/authPeer dependency:
next(optional, required fornextAuth/nextRole)Elysia plugin available at
@hallaxius/auth/elysia— requireselysia >= 1.4.29and@elysiajs/jwtinstalled separately
Table of Contents
- Quick Start
- Quick Start (v1.1+) — Presets
- Configuration
- Elysia Plugin
- Standalone Mode
- Edge / Next.js Middleware
- User Persistence
- Auto-Join Guild
- Security Features
- Utility Helpers
- Security Best Practices
- Migration Guide
- Troubleshooting
- API Reference
- License
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:
Discordclass wrapper is deprecated. UsediscordAuthfrom@hallaxius/auth/elysiainstead.
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 = handleLogoutNext.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):
config.redirectUri(explicit)process.env.DISCORD_REDIRECT_URI(environment variable)- 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:
- Add the full absolute callback URL to your Discord app at
https://discord.com/developers/applications/{your-app-id}/oauth2 - Ensure it matches the
redirectUriyour 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 (
httpvshttps), trailing slashes, and port number. In production, always usehttps. Discord does not allowhttpexcept forlocalhost.
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. Requireselysia >= 1.4.29and@elysiajs/jwtinstalled 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→ loginGET /api/auth/callback→ callbackGET /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
/meroute is registeredrequireRolemacro becomes availablectx.storedUsercontainsSafeStoredUser(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.rawAvailable 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 = handleLoginCallback
// 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 = handleCallbackLogout
// 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 = handleMeWith 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 } = discordEdge / 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/*"]) // falserequiredRole(path, roleMap)
import { requiredRole } from "@hallaxius/auth"
requiredRole("/admin/users", { "/admin/*": ["admin"] }) // ["admin"]
requiredRole("/dashboard", { "/admin/*": ["admin"] }) // nullredirect(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
- First login →
storage.create(data)is called with default role["user"] - Returning user →
storage.update(discordId, data)refreshes tokens + profile - Roles → embedded in JWT at login; checked by
requireRole/withRole - /me endpoint → returns
SafeStoredUser(accessToken/refreshToken excluded) - Route guards →
ctx.storedUsercontainsSafeStoredUser | 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
- 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.
- Invite the bot — Use the OAuth2 > URL Generator tab, select the
botscope and theCreate Instant Invitepermission, then use the generated URL to add the bot to your server. - 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 = handleCallbackMethod 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
botTokencomes from the Bot page in the Dev Portal, not the Client Secret from the OAuth2 page. These are different. - Scope
guilds.joinis required — without it, theaccess_tokencannot join the server. - The bot must be in the server before calling
addMember. Use the URL Generator with thebotscope 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 (
singleUseprevents 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
blockDurationMswhenmaxAttemptsexceeded
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_enabledflag in Discord user object (/users/@me) - Throws
MfaRequiredErrorfor 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_tokento 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
CallbackQuerywith typederrorfield - 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: trueensures 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_tokenJWT 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
-
localhostin 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:
- A random
code_verifieris generated for each login request - The
code_challenge(SHA-256 hash of the verifier) is sent to Discord - When Discord redirects back, the
code_verifieris used to exchange the code for tokens - 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 windowX-RateLimit-Reset: Unix timestamp when rate limit resetsRetry-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: truefor cookies in production -
httpOnly: truefor 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_challengeAfter (v1.0.0):
// PKCE is enabled by default
generateAuthUrl(params) // Includes code_challenge + code_challenge_method=S256If 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