Authentication

Learn how to authenticate users in Svelar using sessions, JWT tokens, or API tokens.

Configuration

Authentication is configured in src/app.ts:

import { AuthManager } from '@beeblock/svelar/auth';
import { User } from './lib/models/User.js';

export const auth = new AuthManager({
  guard: 'session',
  model: User,
  appUrl: process.env.APP_URL ?? 'http://localhost:5173',
  appName: process.env.APP_NAME ?? 'My App',
});

export { AuthManager };

The appUrl and appName are used for generating email links (password reset, email verification) and email template content.

Model Identity

AuthManager reads the configured model's primaryKey, not a hardcoded id or a caller-supplied shadow property. Session/JWT/refresh subjects, API-token owners, OTP/federation login, audit/context identity, password updates and email verification support integer, UUID v7, ULID and custom-named primary keys. Invalid or missing identities cannot create a session or an allowed authentication audit event. Verification links encode opaque identifiers safely.

For a custom User key, use app-owned migrations to give every actual user_id reference the matching type, including auth token/verification, API-key, membership and plugin tables. Changing a Model's primaryKey does not migrate existing columns or foreign keys; the default scaffold remains integer-keyed. Do not convert a real foreign key to text while its referenced key remains an integer.

Account Eligibility

Account status is application-owned. Opt in to the columns and policies present on your user model; add those columns through your application's migrations, never at authentication runtime:

export const auth = new AuthManager({
  guard: 'session',
  model: User,
  eligibility: {
    disabledAtColumn: 'disabled_at',
    lockedUntilColumn: 'locked_until',
    requireVerifiedEmail: true,
  },
});

disabledColumn and lockedColumn support boolean flags; disabledAtColumn supports a nullable timestamp, and lockedUntilColumn a nullable date or epoch-millisecond deadline. Boolean false, zero, '0', 'false', and null mean an unblocked flag. Missing/malformed configured fields fail closed with temporarily_unavailable. A required verification value uses eligibility.verifiedColumn, then emailVerification.verifiedColumn, then email_verified_at. No eligibility configuration preserves the ordinary authentication behavior; it does not infer column names or product-specific roles.

An optional async eligibility.check(user, { method }) can add domain rules. Return { eligible: true } or { eligible: false, reason }; allowed reasons are account_disabled, account_locked, email_unverified, and temporarily_unavailable. Built-in denials cannot be overridden by this callback. Invalid callback results deny authentication, and callback failures propagate rather than authenticating the account.

Eligibility is checked after password proof, before session/JWT issuance, when resolving existing sessions and access tokens, on JWT refresh, and for OTP/passwordless and user API-token authentication. It is rechecked against persisted account state, not cached login eligibility. An ineligible session loses its authentication data and rotates its ID. Registering an account that requires verification returns the persisted user but does not authenticate it. Password reset and email verification remain available to recover access; they do not override disabled/locked account state.

auth.failureReason() exposes the request-local denial code without retaining a user. Wrong passwords still produce invalid_credentials, not account-status information. authFailurePresentation(reason, retryAfter?) provides a domain-independent HTTP status contract: credentials/session expiry 401, account denials 403, rate limiting 429, and provider/backend unavailability 503. Applications own translated messages and inline feedback.

For a verified federation callback, use await auth.login(linkedUser, session, { providerId, remember }) instead of setting auth_user_id directly. The caller must first validate the provider response/state and securely link the local user. login() rereads persisted account state, checks eligibility and optional configured-provider availability, and rotates the session ID. Direct legacy social-session writes cannot bypass eligibility when resolveFromSession() runs.

Public Auth Presentation

AuthConfig.presentation is a server-side contract for branding and real configured federation adapters:

const auth = new AuthManager({
  guard: 'session',
  model: User,
  appName: config.get('app.name'),
  presentation: {
    brand: {
      name: config.get('app.name'),
      title: config.get('auth.brand.title'),
      description: config.get('auth.brand.description'),
      logoUrl: config.get('auth.brand.logoUrl'),
      accentColor: config.get('auth.brand.accentColor'),
    },
    providers: config.get('auth.providers', []),
  },
});

const presentation = auth.presentation();
const availableProviders = presentation.providers.filter((provider) => provider.available);

Brand fields include name, title, description, logo/image URL and alt text, and a hexadecimal accent color. Media URLs must be application-relative or HTTPS without credentials. Each federation entry has id, label, protocol (oauth2, oidc, or saml), an application-relative loginUrl, configured: true, optional available, and optional iconUrl. Configuration is explicit: truthy strings do not enable providers.

auth.presentation() returns owned public objects, strips extra fields such as client secrets, and omits unconfigured providers. Availability defaults to true for configured providers; a temporarily unavailable provider remains identifiable in the contract without requiring a sign-in button. auth.federationProvider(id) returns that public entry or null. Passing an unavailable/unconfigured providerId to login() returns null with provider_unavailable.

Return only this selected presentation data from a page load, never the full server auth/provider configuration. New apps render it through the shared AuthFrame: branding appears across authentication pages, and the login page shows only available, configured providers. The contract does not install OAuth/OIDC/SAML adapters; bind each real adapter before configuring its login URL. Existing customized pages require an explicit reviewed update.

New users migrations include nullable disabled_at and locked_until columns, and their auth configuration explicitly checks them. An existing application's users table is application-owned: add a focused migration before opting into these column checks. Installing a newer package does not silently alter that table or overwrite your auth configuration.

Verified Bearer Authentication

An explicit Bearer credential takes precedence over a session cookie. Invalid, revoked, restricted, rate-limited, or ineligible Bearer credentials do not fall back to the logged-in session. Authentication middleware sets event.locals.authenticatedVia to 'bearer' only after JWT/API-key verification and eligibility checks, to 'session' after eligible session resolution, or to null when unauthenticated. A valid service-account key has a machine actor/API key, not its sponsoring user's session identity.

Use the default createSvelarApp({ auth, ... }) pipeline or place session/authentication before Origin/CSRF middleware in a custom pipeline. A caller-supplied Authorization header is not proof of authentication: Origin/CSRF exemption requires both a Bearer header and the verified authenticatedVia === 'bearer' marker. Session-authenticated mutations retain origin and CSRF validation. Never set this marker from submitted data or use it as a replacement for route authorization.

Authentication Attempt Auditing

Explicit auth attempts delegate to the configured Audit sink by default. Enable persistent audit storage at application boot through Audit.configure({ driver: 'database', enabled: true }) and run the core audit migration. There is no extra template Audit.log() call to add.

Source events are auth.login (password/session or JWT login), auth.passwordless (OTP verification), auth.federation (login()), auth.refresh, denied auth.bearer, auth.logout, and auth.tokens.revoked. Routine session/JWT resolution does not emit per-request events. OTP attemptOtp() uses the verification event rather than emitting a duplicate wrapper event. User API-key creation/control events remain in the API-key module.

Events contain method, outcome, target user ID, denial reason and relevant provider/purpose or affected-token count. Passwords, email identifiers, OTP codes, access/refresh tokens and token hashes are not recorded. The audit sink enriches IP, user-agent, actor, tenant and correlation context. Allowed login events identify the proven user in a temporary audit context without replacing the surrounding caller context.

An allowed event records the authentication decision before granting session/JWT state; it does not promise that subsequent cookie persistence or token delivery succeeded. Required audit errors propagate, set failureReason() to temporarily_unavailable, and prevent a new session/JWT grant. Logout records its decision before clearing credentials; an audit failure leaves the action incomplete and must not be presented as successful logout. Default database refresh-token revocation and its audit share a transaction, so a failed audit write rolls revocation back.

AuthConfig.audit: false or { enabled: false } disables these events for a manager. Omitting it delegates to the global audit configuration, including its enabled flag. An optional { record: async (event) => ... } overrides the sink with an awaited, immutable, typed AuthAuditEvent; application recorders own their storage/transaction behavior. Keep recorders server-only and do not return audit configuration to page loads. Avoid adding template-side copies of events already emitted by these APIs.

Generated Auth Pages

New scaffolds use the same Zod or Valibot contract schemas for Superforms and FormRequests. Page actions authorize through the FormRequest and pass narrowed, typed DTO payloads to the existing registration action or auth APIs. Login/logout and recovery/OTP handlers use the request-local event.locals.auth; do not use the bootstrap auth manager as mutable user state.

Auth page headings and titles read config.get('app.name', process.env.APP_NAME ?? 'Svelar'). Only the public app name and relevant feature flags are returned to the page, not the full auth configuration or secrets. The scaffold does not render nonfunctional OAuth/provider buttons.

Optional config/auth.ts settings override the corresponding environment flags:

export default {
  otp: { enabled: false },
  emailVerification: { required: true },
  redirectTo: '/workspace',
};

OTP is disabled unless AUTH_OTP_ENABLED=true or auth.otp.enabled is the boolean true. Configure real mail delivery before enabling it. Disabled OTP hides the login link, redirects the OTP page to password login, and denies direct send/verify POSTs and API requests with 403. Codes must be six numeric digits. A failed verification preserves the email and retry step, but clears the submitted code; ordinary non-enhanced form posts preserve that step too.

The generated two-step OTP page keeps send and verify as separate Superforms. The send form sets invalidateAll: false: its successful action supplies the next step without launching a background page reload that could overwrite a code being entered. In the verify form's onResult, secret clearing is limited to a server failure containing that verifyForm's matching id and posted === true; send results, unrelated failures and background/unposted form data are not verification attempts. The separate onError still clears the code and reports temporary unavailability. Both Zod and Valibot scaffolds retain the same shared-schema validation and backend auth contracts; do not repair this state race with sleeps, relaxed validation or altered OTP status expectations.

Invalid credentials produce inline, generic feedback without identifying whether the email exists. Login, registration, and password-reset action responses redact password fields on credential, validation, authorization, and rate-limit failures. Enhanced forms clear passwords after failed updates and unexpected errors. Registration/reset also clear password confirmation. Inputs and submit buttons are disabled immediately while submitting; password and OTP inputs expose appropriate autocomplete hints.

Password-recovery success uses the same message for existing and unknown accounts. A rate-limit error is a failure state and keeps the retry form visible. A completed reset redirects to login with visible confirmation. Mail/backend failures are not represented as successful delivery.

Post-auth redirects use an internal path from ?redirectTo=/path, falling back to auth.redirectTo and then /dashboard. Both request and configured destinations are validated: external/protocol-relative URLs, backslashes, control characters, malformed encoding, and normalized protocol-relative paths are rejected. Successful POSTs redirect with 303. This redirect policy does not replace authorization on the destination route.

Session Persistence

Managed sessions support per-login persistence through auth.attempt(credentials, session, { remember: true }) or federation login(). Configure a rememberLifetime on the session middleware (or sessionRememberLifetime on createSvelarApp) before opting in; otherwise remember-me throws instead of promising an unsupported lifetime. Normal logins pass false, and both paths rotate the session ID. Configure normal idle/absolute lifetimes independently; storing an unused form flag is not remembered authentication.

These template fixes do not replace existing apps' customized auth pages automatically. When upgrading an app, reconcile its auth page/controller/request/schema code and auth settings with the scaffold behavior, retaining application-specific policies and branding.

Session-Based Authentication

Session-based auth is the default and recommended approach for web apps. Users log in once and receive a signed session cookie.

Setup

In src/hooks.server.ts:

import { createSvelarHooks } from '@beeblock/svelar/hooks';
import { SessionMiddleware, DatabaseSessionStore } from '@beeblock/svelar/session';
import { AuthenticateMiddleware } from '@beeblock/svelar/auth';
import { auth } from './app.js';

const sessionStore = new DatabaseSessionStore();  // requires the sessions migration

export const handle = createSvelarHooks({
  middleware: [
    new SessionMiddleware({
      store: sessionStore,
      secret: process.env.APP_KEY!,
      lifetime: 60 * 60 * 24, // 24 hours
    }),
    new AuthenticateMiddleware(auth),
  ],
});

User Model

Your User model must have a method to find by a unique identifier (usually email):

import { Model } from '@beeblock/svelar/orm';

export class User extends Model {
  static table = 'users';
  static timestamps = true;
  static fillable = ['name', 'email', 'password'];
  static hidden = ['password'];

  declare id: number;
  declare name: string;
  declare email: string;
  declare password: string;
  declare created_at: Date;
  declare updated_at: Date;
}

Registration

import { User } from '../models/User.js';
import { Hash } from '@beeblock/svelar/hashing';

export class AuthService extends Service {
  async register(data: { name: string; email: string; password: string }) {
    // Check if email already exists
    const existing = await User.where('email', data.email).first();
    if (existing) {
      return this.fail('Email already registered');
    }

    // Hash password
    const hashedPassword = await Hash.make(data.password);

    // Create user
    const user = await User.create({
      name: data.name,
      email: data.email,
      password: hashedPassword,
    });

    return this.ok(user);
  }
}

Register controller:

import { RegisterRequest } from '../dtos/RegisterRequest.js';
import { RegisterUserAction } from '../actions/RegisterUserAction.js';

export class AuthController extends Controller {
  async register(event: any) {
    const data = await RegisterRequest.validate(event);

    const result = await registerAction.run({
      name: data.name,
      email: data.email,
      password: data.password,
    });

    if (!result.success) {
      return this.json({ message: result.error }, 422);
    }

    const user = result.data!;

    // Log user in by setting session
    event.locals.session.set('auth_user_id', (user as any).id);
    event.locals.session.regenerateId();

    return this.created({
      message: 'Registration successful',
      user: { id: (user as any).id, name: (user as any).name, email: (user as any).email },
    });
  }
}

Login

export class AuthService extends Service {
  async login(email: string, password: string) {
    const user = await User.where('email', email).first();
    if (!user) {
      return this.fail('Invalid credentials');
    }

    const valid = await Hash.verify(password, (user as any).password);
    if (!valid) {
      return this.fail('Invalid credentials');
    }

    return this.ok(user);
  }
}

Login controller:

import { LoginRequest } from '../dtos/LoginRequest.js';

export class AuthController extends Controller {
  async login(event: any) {
    const data = await LoginRequest.validate(event);

    const result = await authService.login(data.email, data.password);

    if (!result.success) {
      return this.json({ message: result.error }, 401);
    }

    const user = result.data!;

    // Log user in
    event.locals.session.set('auth_user_id', (user as any).id);
    event.locals.session.regenerateId();

    return this.json({
      message: 'Login successful',
      user: { id: (user as any).id, name: (user as any).name, email: (user as any).email },
    });
  }
}

Logout

export class AuthController extends Controller {
  async logout(event: any) {
    event.locals.session.forget('auth_user_id');
    event.locals.session.regenerateId();

    return this.json({ message: 'Logged out successfully' });
  }
}

Get Current User

The AuthenticateMiddleware automatically resolves the logged-in user and attaches it to event.locals.user:

export class AuthController extends Controller {
  async me(event: any) {
    const user = event.locals.user;

    if (!user) {
      return this.json({ message: 'Unauthenticated' }, 401);
    }

    return this.json({
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: user.created_at,
    });
  }
}

JWT Authentication

For stateless APIs, use JWT (JSON Web Tokens). Each request includes a token in the Authorization header.

Configuration

In src/app.ts:

import { AuthManager } from '@beeblock/svelar/auth';
import { User } from './lib/models/User.js';

export const auth = new AuthManager({
  guard: 'jwt',
  model: User,
  jwt: {
    secret: process.env.JWT_SECRET!,
    expiresIn: '24h',
    algorithm: 'HS256',
  },
});

In src/hooks.server.ts, use AuthenticateMiddleware (it handles both session and JWT):

import { AuthenticateMiddleware } from '@beeblock/svelar/auth';

export const handle = createSvelarHooks({
  middleware: [
    new AuthenticateMiddleware(auth),
  ],
});

Issuing Tokens

import { signJwt } from '@beeblock/svelar/auth';

export class AuthController extends Controller {
  async login(event: any) {
    const result = await authService.login(data.email, data.password);

    if (!result.success) {
      return this.json({ message: result.error }, 401);
    }

    const user = result.data!;

    // Generate JWT token
    const token = signJwt({
      sub: (user as any).id,
      email: (user as any).email,
      name: (user as any).name,
    }, process.env.JWT_SECRET!);

    return this.json({
      message: 'Login successful',
      token,
      user: { id: (user as any).id, name: (user as any).name, email: (user as any).email },
    });
  }
}

Using Tokens

Clients send the token in the Authorization header:

curl -H "Authorization: Bearer eyJhbGc..." http://localhost:5173/api/auth/me

The AuthenticateMiddleware automatically validates the token and sets event.locals.user.

Refresh Tokens

Access tokens are short-lived by design. When they expire, the client needs to re-authenticate. Refresh tokens solve this — they're long-lived tokens stored in the database that can be exchanged for a new access token without re-entering credentials.

Refresh tokens are for JWT authentication only. Session auth uses server-side expiry plus regenerateId(), and API keys use ApiKeys.rotate()/ApiKeys.revoke() instead of refresh tokens.

Enable Refresh Tokens

export const auth = new AuthManager({
  guard: 'jwt',
  model: User,
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: 900,              // Access token: 15 minutes
    algorithm: 'HS256',
    refreshTokens: true,         // Enable refresh tokens
    refreshExpiresIn: 604800,    // Refresh token: 7 days
    refreshTable: 'refresh_tokens', // Database table (default)
  },
});

The refresh_tokens table is managed by Svelar core migrations. Run npx svelar migrate before enabling JWT refresh tokens in an app.

Login — Returns Both Tokens

export class AuthController extends Controller {
  async login(event: any) {
    const data = await LoginRequest.validate(event);

    const result = await auth.attemptJwt({
      email: data.email,
      password: data.password,
    });

    if (!result) {
      return this.json({ message: 'Invalid credentials' }, 401);
    }

    // result includes both tokens when refreshTokens is enabled
    return this.json({
      token: result.token,
      expires_at: result.expiresAt,
      refresh_token: result.refreshToken,
      refresh_expires_at: result.refreshExpiresAt,
    });
  }
}

Refresh — Exchange Refresh Token for New Pair

export class AuthController extends Controller {
  async refresh(event: any) {
    const { refresh_token } = await event.request.json();

    // Exchanges the refresh token for a new access + refresh token pair
    // The old refresh token is automatically revoked (rotation)
    const result = await auth.refreshJwt(refresh_token);

    if (!result) {
      return this.json({ message: 'Invalid or expired refresh token' }, 401);
    }

    return this.json({
      token: result.token,
      expires_at: result.expiresAt,
      refresh_token: result.refreshToken,
      refresh_expires_at: result.refreshExpiresAt,
    });
  }
}

Logout — Revoke All Refresh Tokens

export class AuthController extends Controller {
  async logout(event: any) {
    const user = event.locals.user;
    if (user) {
      // Revoke all refresh tokens for this user
      await auth.revokeRefreshTokens(user.id);
    }

    return this.json({ message: 'Logged out' });
  }
}

How Refresh Token Rotation Works

1. Login         → access_token (15min) + refresh_token_A (7 days)
2. Token expires → client sends refresh_token_A to /api/auth/refresh
3. Server        → revokes refresh_token_A, issues new access_token + refresh_token_B
4. Token expires → client sends refresh_token_B to /api/auth/refresh
5. ...and so on

If refresh_token_A is used again after step 3 → rejected (already revoked)
This detects token theft — if an attacker replays a stolen refresh token,
the legitimate user's next refresh also fails, alerting them.

Client-Side Example

// Store tokens (e.g. in memory or secure storage)
let accessToken = '...';
let refreshToken = '...';

async function fetchWithAuth(url: string, options: RequestInit = {}) {
  let res = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
  });

  // If 401, try refreshing
  if (res.status === 401 && refreshToken) {
    const refreshRes = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refresh_token: refreshToken }),
    });

    if (refreshRes.ok) {
      const data = await refreshRes.json();
      accessToken = data.token;
      refreshToken = data.refresh_token;

      // Retry original request with new token
      res = await fetch(url, {
        ...options,
        headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
      });
    } else {
      // Refresh failed — redirect to login
      window.location.href = '/login';
    }
  }

  return res;
}

API Token Authentication

For machine-to-machine authentication, generate API keys per user or tenant-bound service accounts. The api_keys table is managed by Svelar core migrations.

import { ApiKeys } from '@beeblock/svelar/api-keys';

ApiKeys.configure({ driver: 'database', prefix: 'sk_' });

const { plainTextKey, record } = await ApiKeys.create({
  name: 'GitHub Deploy Key',
  userId: user.id,
  teamId: team.id,
  permissions: ['deployments:create'],
  rateLimit: { limit: 120, window: 60 },
  allowedIps: ['203.0.113.10'],
  expiresIn: 60 * 60 * 24 * 365,
});

The plain text key is returned once. Svelar stores only a hash plus metadata, permissions, timestamps, and revocation state.

Permissions are deny-by-default. A key with an empty permission list authenticates the user but does not pass ApiKeys.hasPermission(). Use explicit abilities such as deployments:create, or use '*' only for a deliberately unrestricted machine token.

For service accounts that should act as a tenant-level machine actor instead of a human user:

const { plainTextKey, record } = await ApiKeys.createServiceAccount({
  name: 'ERP Sync',
  teamId: team.id,
  permissions: ['orders:sync', 'inventory:read'],
  allowedIps: ['203.0.113.10'],
  rateLimit: { limit: 600, window: 60 },
  metadata: { provider: 'erp' },
});

When AuthenticateMiddleware resolves an API key, it sets event.locals.apiKey and enriches the Svelar application context with apiKeyId, teamId/tenantId, actorId, actorType, and serviceAccountId when present. Logs, audit records, jobs, and downstream services can then attribute work to the correct tenant and machine actor.

API keys can be disabled without revoking history:

await ApiKeys.disable(record.id);
await ApiKeys.enable(record.id);
await ApiKeys.revoke(record.id);

If a request exceeds its API key rate limit, AuthenticateMiddleware returns 429 with a Retry-After header. If a key is used from an IP outside allowedIps, it returns 403.

await ApiKeys.rotate(record.id) returns a replacement record and its one-time plain text key, or null if the original is missing or already revoked. It preserves the original absolute expiresAt and disabledAt, tenant/actor/service-account identity, permissions, IP allowlist, rate-limit configuration, and metadata. Rotation does not extend expiration or re-enable a disabled key; an expired or disabled replacement remains unusable.

For database storage, replacement insertion and conditional revocation run in one transaction. Memory storage commits both changes synchronously. Failure leaves the original key and its controls unchanged, with no orphan replacement. Concurrent rotations produce at most one replacement. Successful rotation revokes the original immediately; no overlap window is provided. The replacement has a new ID and fresh usage timestamps, so its per-key rate-limit counter starts a new credential bucket.

Clients send the token in the Authorization header:

curl -H "Authorization: Bearer sk_your_api_key" http://localhost:5173/api/posts

AuthenticateMiddleware validates the key through ApiKeys, resolves the owning user, and sets event.locals.user.

API Request Signatures

Bearer tokens prove who is making the request. Request signatures prove the request hasn't been tampered with. For high-security APIs (payment processing, webhooks, partner integrations), you can require both.

How It Works

The client signs each request by computing an HMAC-SHA256 over the timestamp, HTTP method, path, and body:

signature = HMAC-SHA256(secret, "timestamp.METHOD./path.body")

The server recomputes the signature and rejects requests where:

  • The signature doesn't match (request was tampered with)
  • The timestamp is too old (replay attack)
  • The signature or timestamp header is missing

Server Setup

Add SignatureMiddleware to your pipeline:

import { SignatureMiddleware } from '@beeblock/svelar/middleware';

// Apply to specific paths only
new SignatureMiddleware({
  secret: process.env.API_SIGNING_SECRET!,
  tolerance: 300,           // Reject requests older than 5 minutes
  onlyPaths: ['/api/partner/'], // Only enforce on partner API routes
})

// Or with createSvelarHooks for the full pipeline
export const handle = createSvelarHooks({
  middleware: [
    new CorsMiddleware({ origin: ['https://partner.example.com'] }),
    new SignatureMiddleware({
      secret: process.env.API_SIGNING_SECRET!,
      tolerance: 300,
      onlyPaths: ['/api/partner/'],
    }),
    new AuthenticateMiddleware(auth),
  ],
});

Client-Side Signing (Browser/Node.js)

Use the built-in signedFetch helper:

import { signedFetch } from '@beeblock/svelar/http';

const res = await signedFetch('/api/partner/orders', {
  method: 'POST',
  body: JSON.stringify({ item: 'widget', quantity: 5 }),
  signingSecret: 'your-shared-secret',
});

Client-Side Signing (Any Language)

The signing algorithm is simple enough to implement in any language:

// 1. Get current Unix timestamp (seconds)
const timestamp = Math.floor(Date.now() / 1000);

// 2. Build the signing payload
const payload = `${timestamp}.POST./api/partner/orders.{"item":"widget"}`;

// 3. Compute HMAC-SHA256
const signature = hmacSHA256(secret, payload);

// 4. Send with headers
fetch('/api/partner/orders', {
  method: 'POST',
  headers: {
    'X-Signature': signature,
    'X-Timestamp': String(timestamp),
    'Content-Type': 'application/json',
  },
  body: '{"item":"widget"}',
});
# cURL example
TIMESTAMP=$(date +%s)
BODY='{"item":"widget"}'
PAYLOAD="${TIMESTAMP}.POST./api/partner/orders.${BODY}"
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "your-secret" -hex | awk '{print $2}')

curl -X POST https://myapp.com/api/partner/orders \
  -H "Content-Type: application/json" \
  -H "X-Signature: $SIGNATURE" \
  -H "X-Timestamp: $TIMESTAMP" \
  -d "$BODY"

Signature Options

Option Default Description
secret (required) Shared signing secret
tolerance 300 Max request age in seconds (5 min)
signatureHeader 'X-Signature' Header name for the HMAC signature
timestampHeader 'X-Timestamp' Header name for the Unix timestamp
onlyPaths null Path prefixes to enforce (null = all)

Bearer Token + Signature (Double Protection)

For maximum security, combine both:

// Client sends both
fetch('/api/partner/orders', {
  headers: {
    'Authorization': `Bearer sk_a1b2c3...`,   // Proves identity
    'X-Signature': signature,                  // Proves integrity
    'X-Timestamp': String(timestamp),
  },
  body: '...',
});

The AuthenticateMiddleware resolves the user from the Bearer token, while SignatureMiddleware verifies the request wasn't tampered with. Both must pass.

Middleware

AuthenticateMiddleware

Resolves the authenticated user from session, JWT, or API token:

import { AuthenticateMiddleware } from '@beeblock/svelar/auth';

export const handle = createSvelarHooks({
  middleware: [
    new AuthenticateMiddleware(auth),
  ],
});

After this middleware, event.locals.user is either the User model or null.

RequireAuthMiddleware

Ensures a user is authenticated. Returns 401 if not:

import { RequireAuthMiddleware } from '@beeblock/svelar/auth';

export const handle = createSvelarHooks({
  middleware: [
    new AuthenticateMiddleware(auth),
    new RequireAuthMiddleware(), // Require auth for all routes
  ],
});

Protecting Routes

With Middleware

Apply RequireAuthMiddleware to specific routes:

export class DashboardController extends Controller {
  constructor() {
    super();
    this.middleware('auth').only(['dashboard', 'settings']);
  }

  async dashboard(event: any) {
    // User is guaranteed to be authenticated
    const user = event.locals.user;
    return this.json({ message: `Welcome ${user.name}` });
  }

  async settings(event: any) {
    const user = event.locals.user;
    return this.json({ user });
  }

  async home(event: any) {
    // No auth required
    return this.json({ message: 'Public endpoint' });
  }
}

In Controllers

Check event.locals.user manually:

export class PostController extends Controller {
  async store(event: any) {
    const data = await CreatePostRequest.validate(event);

    if (!event.locals.user) {
      return this.json({ message: 'Unauthenticated' }, 401);
    }

    const post = await Post.create({
      ...data,
      user_id: event.locals.user.id,
    });

    return this.created(post);
  }
}

Session cookies are the primary mechanism for maintaining authentication state. Svelar configures secure defaults automatically.

Option Default Description
cookieName 'svelar_session' Cookie name
lifetime 7200 (2 hours) Session lifetime in seconds
httpOnly true Prevents JavaScript access (XSS protection)
secure NODE_ENV === 'production' HTTPS-only in production
sameSite 'lax' Prevents CSRF on cross-origin POST
domain undefined Cookie domain (defaults to current host)
path '/' Cookie path

Override defaults in createSvelarApp or SessionMiddleware:

// Using createSvelarApp (recommended)
export const { handle, handleError } = createSvelarApp({
  auth,
  secret: process.env.APP_KEY,
  sessionLifetime: 60 * 60 * 24 * 7, // 7 days
  sessionStore: new DatabaseSessionStore('sessions'),
});

// Using SessionMiddleware directly
new SessionMiddleware({
  store: new DatabaseSessionStore('sessions'),
  secret: process.env.APP_KEY!,
  lifetime: 60 * 60 * 24 * 7,  // 7 days
  name: 'my_app_session',       // Custom cookie name
})

Production Security Checklist

  1. Always set a strong APP_KEY — generate a random 32+ character string (openssl rand -hex 32)
  2. Use DatabaseSessionStore — MemorySessionStore loses all sessions on restart
  3. Use HTTPS — secure: true is automatic when NODE_ENV=production
  4. httpOnly: true is on by default — never disable it unless you have a very specific reason
  5. sameSite: 'lax' blocks cross-origin POST with cookies — use 'strict' for extra security if your app has no cross-origin navigation needs
  6. Regenerate session IDs after login/logout to prevent session fixation:
event.locals.session.set('auth_user_id', user.id);
event.locals.session.regenerateId(); // Always do this after login

Hashing

Svelar provides multiple hashing drivers for password security with timing-safe comparison.

Configuration

In src/app.ts:

import { Hash } from '@beeblock/svelar/hashing';

Hash.configure({
  driver: 'scrypt',       // 'scrypt', 'bcrypt', 'argon2'
  scryptCost: 16384,      // scrypt N parameter (default: 16384)
  bcryptRounds: 12,       // bcrypt cost factor (default: 12)
});

Hashing Passwords

import { Hash } from '@beeblock/svelar/hashing';

// Hash password
const hashedPassword = await Hash.make('user-password');

// Verify password (timing-safe comparison)
const isValid = await Hash.verify('user-password', hashedPassword);

Automatic Rehashing

When you change hash drivers or increase cost parameters, existing hashes still verify but may need upgrading. Use needsRehash() to detect this:

async login(email: string, password: string) {
  const user = await User.where('email', email).first();
  if (!user) return this.fail('Invalid credentials');

  const valid = await Hash.verify(password, user.password);
  if (!valid) return this.fail('Invalid credentials');

  // Rehash if the algorithm or cost changed
  if (Hash.needsRehash(user.password)) {
    user.password = await Hash.make(password);
    await user.save();
  }

  return this.ok(user);
}

This lets you transparently migrate from bcrypt to argon2 (or increase cost) without forcing all users to reset passwords.

Hash Drivers

Driver Dependencies Best For
scrypt (default) None (Node.js built-in) Most apps — zero setup, strong security
bcrypt npm install bcrypt Apps migrating from other frameworks
argon2 npm install argon2 Maximum security — memory-hard, GPU-resistant

The scaffolded vite.config.ts marks bcrypt and argon2 as SSR externals so Vite does not try to bundle their native Node modules for production. Keep that setting if you use either driver:

export default defineConfig({
  ssr: {
    external: ['bcrypt', 'argon2'],
  },
});

Cost Parameters

Higher cost = slower hashing = harder to brute force, but also slower login:

// Increase scrypt cost (default 16384, must be power of 2)
Hash.configure({ driver: 'scrypt', scryptCost: 32768 });

// Increase bcrypt rounds (default 12, each +1 doubles the time)
Hash.configure({ driver: 'bcrypt', bcryptRounds: 14 });

Tip: The defaults are secure for most applications. Only increase if you have specific compliance requirements or threat models that demand it.

Session Store

Memory Store (Development)

import { MemorySessionStore } from '@beeblock/svelar/session';

const sessionStore = new MemorySessionStore();

export const handle = createSvelarHooks({
  middleware: [
    new SessionMiddleware({
      store: sessionStore,
      secret: process.env.APP_KEY!,
      lifetime: 60 * 60 * 24,
    }),
  ],
});

Sessions are stored in memory and lost on restart. Great for development.

Database Store (Production)

import { DatabaseSessionStore } from '@beeblock/svelar/session';

const sessionStore = new DatabaseSessionStore('sessions');

export const handle = createSvelarHooks({
  middleware: [
    new SessionMiddleware({
      store: sessionStore,
      secret: process.env.APP_KEY!,
      lifetime: 60 * 60 * 24,
    }),
  ],
});

Sessions are persisted to the database and survive restarts.

Complete Auth Example

Here's the complete authentication flow from a scaffolded Svelar project:

Routes

// src/routes/api/auth/register/+server.ts        → POST
// src/routes/api/auth/login/+server.ts            → POST
// src/routes/api/auth/logout/+server.ts           → POST
// src/routes/api/auth/me/+server.ts               → GET
// src/routes/api/auth/forgot-password/+server.ts  → POST
// src/routes/api/auth/reset-password/+server.ts   → POST
// src/routes/api/auth/otp/send/+server.ts         → POST
// src/routes/api/auth/otp/verify/+server.ts       → POST
// src/routes/api/auth/verify-email/+server.ts     → GET

// Each route follows the same pattern:
import { AuthController } from '$lib/controllers/AuthController.js';
const ctrl = new AuthController();
export const POST = ctrl.handle('methodName');

Controller

// src/lib/controllers/AuthController.ts
export class AuthController extends Controller {
  async register(event: any) {
    const data = await RegisterRequest.validate(event);
    const result = await registerAction.run(data);

    if (!result.success) {
      return this.json({ message: result.error }, 422);
    }

    const user = result.data!;
    event.locals.session.set('auth_user_id', (user as any).id);
    event.locals.session.regenerateId();

    return this.created({
      message: 'Registration successful',
      user: { id: (user as any).id, name: (user as any).name, email: (user as any).email },
    });
  }

  async login(event: any) {
    const data = await LoginRequest.validate(event);
    const result = await authService.login(data.email, data.password);

    if (!result.success) {
      return this.json({ message: result.error }, 401);
    }

    const user = result.data!;
    event.locals.session.set('auth_user_id', (user as any).id);
    event.locals.session.regenerateId();

    return this.json({
      message: 'Login successful',
      user: { id: (user as any).id, name: (user as any).name, email: (user as any).email },
    });
  }

  async logout(event: any) {
    event.locals.session.forget('auth_user_id');
    event.locals.session.regenerateId();

    return this.json({ message: 'Logged out successfully' });
  }

  async me(event: any) {
    const user = event.locals.user;

    if (!user) {
      return this.json({ message: 'Unauthenticated' }, 401);
    }

    return this.json({
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: user.created_at,
    });
  }
}

Password Reset

Built-in password reset flow with token generation, email sending, and token validation. Required tables are managed by Svelar core migrations.

How It Works

1. User submits email to /api/auth/forgot-password
2. Server generates a token, stores its hash in password_resets table
3. Server sends the "password-reset" email template with a reset link
4. User clicks the link, submits new password to /api/auth/reset-password
5. Server validates the token, updates the password, revokes all refresh tokens

API

// Send reset email (always returns success to avoid leaking user existence)
await auth.sendPasswordReset('user@example.com');

// Reset password with token from email link
const success = await auth.resetPassword(token, email, newPassword);

Routes (scaffolded by npx @beeblock/svelar new)

// POST /api/auth/forgot-password
// Body: { email: "user@example.com" }
// Response: { message: "If that email exists, a reset link has been sent." }

// POST /api/auth/reset-password
// Body: { token: "...", email: "user@example.com", password: "newpassword" }
// Response: { message: "Password has been reset. You can now log in." }

Configuration

export const auth = new AuthManager({
  guard: 'session',
  model: User,
  appUrl: process.env.APP_URL,
  appName: process.env.APP_NAME,
  passwordResets: {
    table: 'password_resets',    // default
    expiresIn: 3600,             // 1 hour (default)
  },
});

Email Verification

Verify user email addresses after registration. The verification token is sent via the "email-verification" email template.

API

// Send verification email
await auth.sendVerificationEmail(user);

// Verify email with token
const success = await auth.verifyEmail(token, userId);

// Check if user has verified their email
const verified = auth.isEmailVerified(user);

Sending on Registration

// In your register action or controller
const user = await auth.register({
  name: data.name,
  email: data.email,
  password: data.password,
});

// Send verification email
await auth.sendVerificationEmail(user);

Routes (scaffolded by npx @beeblock/svelar new)

// GET /api/auth/verify-email?token=...&id=...
// Response: { message: "Email verified successfully" }

Requiring Verified Email

AuthManager can enforce verification across session, JWT, refresh, passwordless, and user API-token authentication:

const auth = new AuthManager({
  guard: 'session',
  model: User,
  eligibility: { requireVerifiedEmail: true },
});

Alternatively, keep authentication permissive and enforce verification only on selected controllers/middleware:

// In a controller or middleware
if (!auth.isEmailVerified(event.locals.user)) {
  return new Response(JSON.stringify({ message: 'Please verify your email address' }), {
    status: 403,
    headers: { 'Content-Type': 'application/json' },
  });
}

Configuration

export const auth = new AuthManager({
  guard: 'session',
  model: User,
  emailVerification: {
    table: 'email_verifications',   // default
    expiresIn: 86400,               // 24 hours (default)
    verifiedColumn: 'email_verified_at', // default
  },
});

Add email_verified_at to your users migration:

table.dateTime('email_verified_at').nullable();

OTP (One-Time Password) Login

Passwordless authentication via one-time codes sent by email. Users receive a 6-digit numeric code they enter to log in.

How It Works

1. User enters their email on the login page
2. POST /api/auth/otp/send → generates 6-digit code, stores hash, emails code
3. User enters the code they received
4. POST /api/auth/otp/verify → validates code, creates session

API

// Send OTP code
await auth.sendOtp('user@example.com');            // purpose: 'login' (default)
await auth.sendOtp('user@example.com', 'confirm'); // custom purpose

// Verify OTP without creating session (useful for 2FA)
const user = await auth.verifyOtp(email, code);

// Verify OTP and create session in one step (OTP login)
const user = await auth.attemptOtp(email, code, session);

Routes (scaffolded by npx @beeblock/svelar new)

// POST /api/auth/otp/send
// Body: { email: "user@example.com" }
// Response: { message: "If that email exists, a verification code has been sent." }

// POST /api/auth/otp/verify
// Body: { email: "user@example.com", code: "482917" }
// Response: { message: "Login successful", user: { id, name, email } }

Configuration

export const auth = new AuthManager({
  guard: 'session',
  model: User,
  otp: {
    table: 'otp_codes',  // default
    expiresIn: 600,       // 10 minutes (default)
    length: 6,            // code length (default)
  },
});

Using OTP as Two-Factor Authentication

Combine password login with OTP verification for 2FA:

// Step 1: User logs in with email+password
const user = await auth.attempt(credentials);
if (!user) return this.json({ message: 'Invalid credentials' }, 401);

// Step 2: Send OTP for second factor
await auth.sendOtp(user.getAttribute('email'), 'two-factor');

// Don't create session yet — wait for OTP
return this.json({ message: 'Verification code sent', requiresOtp: true });

// Step 3: User submits OTP
const verified = await auth.verifyOtp(email, code, 'two-factor');
if (!verified) return this.json({ message: 'Invalid code' }, 401);

// Now create session
session.set('auth_user_id', verified.getAttribute('id'));
session.regenerateId();

Auth Email Templates

Svelar includes 4 built-in email templates for auth flows. They're registered automatically when you call EmailTemplates.registerDefaults() (included in the scaffold):

Template Variables Used By
welcome appName, user.name, user.email, confirmUrl Registration
password-reset appName, user.name, resetUrl sendPasswordReset()
email-verification user.name, verifyUrl sendVerificationEmail()
otp-code appName, user.name, code, expiresMinutes, purpose sendOtp()

Customize any template by updating it after registration:

import { EmailTemplates } from '@beeblock/svelar/email-templates';

await EmailTemplates.update('welcome', {
  subject: 'Welcome to {{appName}}!',
  html: '<h1>Welcome {{user.name}}</h1><p>Your custom HTML here.</p>',
});

When using the file-backed email template driver, missing template files fall back to the built-in defaults. Malformed template JSON and filesystem errors throw instead of being treated as missing templates.

Password reset and OTP requests still return false for unknown users so the app can keep account existence private. For existing users, template rendering failures and mail provider failures throw. Do not catch and ignore those errors in registration, reset, verification, or OTP flows unless you intentionally want to accept a token that the user may never receive.

Token Cleanup

Password reset, email verification, and OTP values are stored as HMAC hashes and validated after a scoped lookup with constant-time hash comparison. OTP codes are generated with unbiased numeric randomness, restricted to 4-12 digits, and marked as used after the first successful verification.

Expired tokens from password resets, email verifications, and OTP codes should be cleaned up periodically. The cleanupExpiredTokens() method handles all three:

const result = await auth.cleanupExpiredTokens();
// { passwordResets: 5, verifications: 2, otpCodes: 12 }

This is wired up automatically in the scaffolded CleanupExpiredTokens scheduled task (runs daily).

The password reset, email verification, OTP, refresh token, and session tables are managed by Svelar core migrations. Run npx svelar migrate before enabling auth cleanup or scheduled session cleanup. Missing auth tables fail loudly instead of being ignored.

Session stores treat missing or corrupted session payloads as an invalid session and return null; corrupted file, database, and Redis session records are removed. Storage, database, and Redis infrastructure errors are not swallowed.

Choosing an Auth Strategy

Svelar supports three authentication methods. They can be used independently or combined — the AuthenticateMiddleware tries them all automatically.

Comparison

Session JWT API Token
How it works Signed cookie with session ID Self-contained signed token Opaque random string stored in DB
Stateless No (server stores session) Yes (no server storage) No (database lookup)
Expiration lifetime config (default: 2h) Built-in exp claim (default: 1h) Optional, can be permanent
Revocation Destroy session instantly Not possible without a blacklist Instant via revokedAt flag
Permissions Via roles/permissions on user Custom claims possible Built-in per-token granular permissions
Rotation Automatic via regenerateId() Generate a new token manually Built-in rotate() method
Audit trail Session store timestamps None lastUsedAt, createdAt, revokedAt
DB lookups per request 1 (session store) 1 (user by sub claim) 1-2 (token + user lookup)
CSRF protection needed Yes (browser sends cookies automatically) No (token sent explicitly) No (token sent explicitly)

When to Use What

Session — web apps where users log in via a browser:

  • Default and recommended for most apps
  • Cookie is httpOnly and secure in production — safe from XSS
  • Works with createSvelarApp out of the box

JWT — stateless APIs, mobile apps, SPAs:

  • No database hit to verify the token (HMAC signature check only)
  • Token expires and cannot be revoked — keep lifetimes short
  • Good for microservices and horizontal scaling

API Token — server-to-server, CI/CD, developer integrations:

  • User generates a named token from their dashboard (e.g., "GitHub Deploy Key")
  • Token can have scoped permissions (['posts:read', 'posts:write'])
  • Can be revoked instantly if compromised
  • Rotate with ApiKeys.rotate() when credentials need replacing
  • Tracks when it was last used — great for audit logs

How the Middleware Resolves Auth

The AuthenticateMiddleware tries all three strategies in order on every request:

1. Session     — checks event.locals.session for auth_user_id
2. JWT         — if Authorization: Bearer <token>, tries JWT verification (fast, no DB)
3. API Token   — if JWT fails, falls back to database token lookup

Both JWT and API tokens use the same Authorization: Bearer header — the middleware detects the type automatically. This means your API endpoints accept both without any extra configuration.

// All three work against the same endpoint:

// Session (browser with cookie)
fetch('/api/posts', { credentials: 'include' });

// JWT
fetch('/api/posts', { headers: { Authorization: `Bearer ${jwtToken}` } });

// API token
fetch('/api/posts', { headers: { Authorization: `Bearer sk_a1b2c3d4...` } });

Combining Strategies

A common pattern is session auth for the web UI + API tokens for integrations:

// app.ts — session guard for web, API tokens handled automatically
export const auth = new AuthManager({
  guard: 'session',
  model: User,
});

If you also need JWT (e.g., for a mobile app), add the JWT config:

export const auth = new AuthManager({
  guard: 'session',
  model: User,
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: 3600,
    algorithm: 'HS256',
  },
});

The middleware will try session first, then JWT, then API token — no route changes needed.

Best Practices

  1. Use sessions for web apps — simpler, revocable, CSRF-protected by default
  2. Use JWT for mobile/SPA APIs — stateless, fast, but keep lifetimes short (1-24h)
  3. Use API tokens for integrations — revocable, auditable, scoped permissions
  4. Always hash passwords — never store plain text passwords
  5. Regenerate session IDs after login/logout — prevents session fixation attacks
  6. Use HTTPS in production — protect tokens and cookies in transit
  7. Set appropriate token lifetimes — balance security and convenience
  8. Use needsRehash() on login — transparently upgrade hash algorithms over time
  9. Scope API tokens narrowly — give each token only the permissions it needs

Next Steps


Svelar Authentication Guide © 2026

Svelar © 2026 · MIT License