Skip to main content

Overview

XyraPanel uses Better Auth as its authentication foundation, providing a secure, type-safe authentication system with support for:
  • Email/password authentication
  • Username-based login
  • Two-factor authentication (TOTP)
  • API key authentication
  • OAuth providers (extensible)
  • Session management
  • Multi-session support
  • Account linking
  • Admin impersonation

Better Auth Configuration

The authentication system is configured in server/utils/auth.ts:
// From: server/utils/auth.ts
export function createAuth() {
  const runtimeConfig = useRuntimeConfig();
  const db = useAuthDb();
  
  return betterAuth({
    database: drizzleAdapter(db, {
      provider: 'pg',
      schema: {
        user: tables.users,
        session: tables.sessions,
        account: tables.accounts,
        verificationToken: tables.verificationTokens,
        rateLimit: tables.rateLimit,
        apikey: tables.apiKeys,
        twoFactor: tables.twoFactor,
        jwks: tables.jwks,
      },
    }),
    
    session: {
      expiresIn: 14 * 24 * 60 * 60, // 14 days
      updateAge: 12 * 60 * 60,      // 12 hours
      freshAge: 5 * 60,             // 5 minutes
      cookieCache: {
        enabled: true,
        maxAge: 5 * 60,
        strategy: 'compact',
      },
    },
    
    plugins: [
      username(),
      twoFactor(),
      admin(),
      apiKey(),
      bearer(),
      multiSession({ maximumSessions: 5 }),
      customSession(),
    ],
  });
}

Authentication Methods

Username/Password Authentication

Users can authenticate with either username or email:
// Email-based login
const result = await authClient.signIn.email({
  email: '[email protected]',
  password: 'secure-password',
  rememberMe: true,
});

// Username-based login
const result = await authClient.signIn.username({
  username: 'john',
  password: 'secure-password',
  rememberMe: true,
});
Password Requirements:
  • Hashed using bcrypt with cost factor 12
  • Minimum length enforced by validation
  • Can be force-reset by administrators
// From: server/utils/auth.ts
password: {
  hash: async (password: string) => {
    return await bcrypt.hash(password, 12);
  },
  verify: async ({ hash, password }: { hash: string; password: string }) => {
    return await bcrypt.compare(password, hash);
  },
}

Two-Factor Authentication (2FA)

XyraPanel supports TOTP-based 2FA:
// Enable 2FA
const { secret, qrCode } = await authClient.twoFactor.enable();

// Verify setup
await authClient.twoFactor.verify({
  code: '123456',
});

// Login with 2FA
const result = await authClient.signIn.username({
  username: 'john',
  password: 'password',
});

if (result.twoFactorRequired) {
  await authClient.twoFactor.verify({
    code: '123456',
  });
}
2FA Configuration:
twoFactor({
  issuer: runtimeConfig.public.appName || 'XyraPanel',
})
Backup codes are stored in the two_factor table for account recovery.

API Key Authentication

API keys provide programmatic access to the panel:
// Create an API key
const apiKey = await authClient.apiKey.create({
  name: 'My API Key',
  expiresIn: 90, // days
  permissions: {
    servers: ['read', 'write'],
    users: ['read'],
  },
});

// Use the API key
fetch('/api/servers', {
  headers: {
    'Authorization': `Bearer ${apiKey.key}`,
    // or
    'X-API-Key': apiKey.key,
  },
});
API Key Features:
  • Hashed storage for security
  • Custom expiration times (1-90 days)
  • Per-resource permissions
  • Rate limiting support
  • Last used tracking
// From: server/utils/auth.ts
apiKey({
  apiKeyHeaders: ['x-api-key'],
  customAPIKeyGetter: (ctx: ApiKeyRequestContext) => {
    const bearerHeader = ctx.headers?.get('authorization');
    if (bearerHeader?.startsWith('Bearer ')) {
      return bearerHeader.slice(7).trim();
    }
    return ctx.headers?.get('x-api-key') || null;
  },
  enableSessionForAPIKeys: true,
  disableKeyHashing: false,
  defaultKeyLength: 32,
  fallbackToDatabase: true,
  keyExpiration: {
    defaultExpiresIn: 60 * 60 * 24 * 90, // 90 days
    minExpiresIn: 1,
    maxExpiresIn: 90,
  },
})

OAuth Providers

Better Auth supports OAuth providers for social login (extensible):
// Example: GitHub OAuth
account: {
  fields: {
    providerId: 'provider',
    accountId: 'providerAccountId',
  },
  accountLinking: {
    enabled: true,
    allowDifferentEmails: false,
    updateUserInfoOnLink: true,
  },
}

Session Management

Session Lifecycle

session: {
  expiresIn: 14 * 24 * 60 * 60,  // Session expires after 14 days
  updateAge: 12 * 60 * 60,        // Session refreshed every 12 hours
  freshAge: 5 * 60,               // Session considered fresh for 5 minutes
  cookieCache: {
    enabled: true,
    maxAge: 5 * 60,               // Cache session in cookie for 5 minutes
    strategy: 'compact',
  },
}

Multi-Session Support

Users can have up to 5 active sessions:
multiSession({
  maximumSessions: 5,
})
Sessions are tracked with metadata:
// From: server/database/schema.ts
export const sessionMetadata = pgTable('session_metadata', {
  sessionToken: text('session_token').primaryKey(),
  firstSeenAt: timestamp('first_seen_at', { mode: 'string' }),
  lastSeenAt: timestamp('last_seen_at', { mode: 'string' }),
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  deviceName: text('device_name'),
  browserName: text('browser_name'),
  osName: text('os_name'),
});

Session Impersonation

Administrators can impersonate users:
admin({
  adminRoles: ['admin'],
  defaultRole: 'user',
  impersonationSessionDuration: 60 * 60, // 1 hour
  defaultBanReason: 'No reason provided',
  bannedUserMessage: 'You have been banned from this application.',
})
Impersonation is tracked in the sessions table:
impersonatedBy: text('impersonated_by').references(() => users.id, { onDelete: 'set null' })

Global Authentication Middleware

All requests pass through the global auth middleware (server/middleware/auth.global.ts):
export default defineEventHandler(async (event) => {
  const path = getRequestURL(event).pathname;
  
  // Skip assets and public paths
  if (isAssetPath(path) || isPublicApiPath(path) || isPublicPagePath(path)) {
    return;
  }
  
  // Check for API key authentication
  if (path.startsWith('/api/')) {
    const authorization = getHeader(event, 'authorization');
    const apiKey = getHeader(event, 'x-api-key');
    
    if (apiKey || authorization?.startsWith('Bearer ')) {
      const apiKeyValue = apiKey || authorization?.slice(7);
      
      const verification = await auth.api.verifyApiKey({
        body: { key: apiKeyValue },
        headers: getAuthHeaders(event),
      });
      
      if (verification.valid) {
        // Set API key context
        event.context.auth = {
          session: null,
          user: { id: verification.key.userId, ... },
          apiKey: {
            id: verification.key.id,
            userId: verification.key.userId,
            permissions: verification.key.permissions,
          },
        };
        return;
      }
    }
  }
  
  // Check session authentication
  const session = await getServerSession(event);
  
  if (!session?.user?.id) {
    if (path.startsWith('/api/')) {
      throw createError({ status: 401, message: 'Authentication required.' });
    }
    return redirectToLogin(event, requestUrl);
  }
  
  // Set session context
  event.context.auth = {
    session: { ...session, user },
    user,
  };
});

Protected Routes

Protected Pages:
  • / (Dashboard)
  • /account/* (Account settings)
  • /admin/* (Admin panel - requires admin role)
  • /server/* (Server management)
Protected API Routes:
  • All routes except:
    • /api/auth/* (Authentication endpoints)
    • /api/account/register (Registration)
    • /api/branding (Public branding)
    • /api/remote/* (Wings callbacks)
    • /api/system (System status)

Rate Limiting

Better Auth includes built-in rate limiting:
rateLimit: {
  enabled: true,
  window: 60,  // 60 seconds
  max: 100,    // 100 requests per window
  storage: 'database',
  customRules: {
    '/sign-in/email': {
      window: 10,
      max: 3,  // 3 attempts per 10 seconds
    },
    '/sign-in/username': {
      window: 10,
      max: 3,
    },
    '/two-factor/verify': {
      window: 10,
      max: 3,
    },
    '/change-password': {
      window: 60,
      max: 5,
    },
    '/api-key/create': {
      window: 60,
      max: 10,
    },
  },
}
Rate limits are stored in the database:
export const rateLimit = pgTable(
  'rate_limit',
  {
    id: text('id').primaryKey(),
    key: text('key').notNull().unique(),
    count: integer('count').notNull().default(0),
    lastRequest: bigint('last_request', { mode: 'number' }).notNull(),
  },
  (table) => [
    index('rate_limit_key_index').on(table.key),
    index('rate_limit_last_request_index').on(table.lastRequest),
  ],
);

Security Features

Secret Validation

Production environments enforce strong secrets:
function assertSecretSecurity(isProduction: boolean, secret: string | undefined): void {
  if (!isProduction) {
    return;
  }
  
  if (!secret || secret.length < 32) {
    throw new Error('BETTER_AUTH_SECRET must be at least 32 characters in production.');
  }
  
  const weakSecretPatterns = [
    'changeme', 'change-me', 'password', 'secret',
    'xyrapanel', 'default', 'example',
  ];
  
  const normalizedSecret = secret.toLowerCase();
  if (weakSecretPatterns.some(pattern => normalizedSecret.includes(pattern))) {
    throw new Error('BETTER_AUTH_SECRET appears weak or default-like.');
  }
}

CSRF Protection

CSRF protection is enabled by default:
advanced: {
  disableCSRFCheck: false,
  disableOriginCheck: !isProduction,
  useSecureCookies: isProduction,
}

Trusted Origins

Only configured origins can authenticate:
const trustedOrigins: string[] = [];

if (baseURL) {
  trustedOrigins.push(baseURL);
}

if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
  const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
    .split(',')
    .map(origin => origin.trim())
    .filter(Boolean);
  trustedOrigins.push(...additionalOrigins);
}

IP Address Detection

Supports various proxy headers:
const ipAddressHeaders = process.env.BETTER_AUTH_IP_HEADER
  ? [process.env.BETTER_AUTH_IP_HEADER]
  : ['cf-connecting-ip', 'x-forwarded-for', 'x-real-ip'];

Client-Side Authentication

The client uses the Better Auth Vue plugin:
// From: app/utils/auth-client.ts
import { createAuthClient } from 'better-auth/vue';
import {
  usernameClient,
  twoFactorClient,
  customSessionClient,
  apiKeyClient,
  adminClient,
  multiSessionClient,
} from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  plugins: [
    usernameClient(),
    twoFactorClient({
      onTwoFactorRedirect() {},
    }),
    apiKeyClient(),
    adminClient(),
    multiSessionClient(),
    customSessionClient<typeof auth>(),
  ],
});

Usage in Components

<script setup lang="ts">
import { authClient } from '~/utils/auth-client';

const { data: session } = authClient.useSession();

const signIn = async () => {
  await authClient.signIn.username({
    username: 'john',
    password: 'password',
  });
};

const signOut = async () => {
  await authClient.signOut();
};
</script>

<template>
  <div v-if="session">
    <p>Welcome, {{ session.user.username }}!</p>
    <button @click="signOut">Sign Out</button>
  </div>
  <div v-else>
    <button @click="signIn">Sign In</button>
  </div>
</template>

Email Notifications

Authentication triggers various email notifications:

Password Reset

sendResetPassword: async ({ user, token }, _request) => {
  const { sendPasswordResetEmail, resolvePanelBaseUrl } = await import('#server/utils/email');
  const resetBaseUrl = `${resolvePanelBaseUrl()}/auth/password/reset`;
  void sendPasswordResetEmail(user.email, token, resetBaseUrl);
}

Email Verification

emailVerification: {
  sendOnSignUp: true,
  sendOnSignIn: false,
  autoSignInAfterVerification: true,
  expiresIn: 60 * 60 * 24, // 24 hours
  sendVerificationEmail: async ({ user, token }) => {
    const { sendEmailVerificationEmail } = await import('#server/utils/email');
    void sendEmailVerificationEmail({
      to: user.email,
      token,
      expiresAt: new Date(Date.now() + 60 * 60 * 24 * 1000),
      username: user.username,
    });
  },
}

Account Deletion

deleteUser: {
  enabled: true,
  sendDeleteAccountVerification: async ({ user, url }) => {
    const { sendEmail } = await import('#server/utils/email');
    void sendEmail({
      to: user.email,
      subject: 'Confirm Account Deletion',
      html: `
        <h2>Confirm Account Deletion</h2>
        <p>Click the link below to confirm account deletion:</p>
        <p><a href="${url}">Delete My Account</a></p>
      `,
    });
  },
  beforeDelete: async (user) => {
    // Prevent admin deletion
    const dbUser = await db.select().from(tables.users).where(eq(tables.users.id, user.id));
    if (dbUser[0]?.rootAdmin || dbUser[0]?.role === 'admin') {
      throw new APIError('BAD_REQUEST', {
        message: 'Admin accounts cannot be deleted',
      });
    }
  },
}

Environment Variables

BETTER_AUTH_SECRET
string
required
Secret key for signing tokens (min 32 characters in production)
BETTER_AUTH_URL
string
required
Base URL of the panel (required in production)
BETTER_AUTH_TRUSTED_ORIGINS
string
Comma-separated list of trusted origins for CORS
BETTER_AUTH_IP_HEADER
string
Custom header for client IP detection (default: cf-connecting-ip, x-forwarded-for, x-real-ip)
CAPTCHA_PROVIDER
string
CAPTCHA provider: turnstile, recaptcha, or hcaptcha

Next Steps

Wings Integration

Learn about Wings integration

Database Schema

Explore the database schema

Build docs developers (and LLMs) love