Authentication

@hypequery/serve keeps authentication explicit: you provide AuthStrategy functions that accept the incoming request (or embedded call) and return your auth context. This page shows how to register strategies globally, override them per-endpoint, and propagate auth data through middleware or embedded calls.

Core concepts

  • Auth strategies run before middleware/handlers. They receive { request, endpoint } and return an AuthContext object or null.
  • Global vs per-endpoint – pass auth in defineServe to enforce auth across every query. Individual queries can provide their own auth to override or disable auth.
  • Auth context is injected into ctx.auth and forwarded to tenant helpers, middlewares, and hooks.
  • Failures – returning null triggers an UNAUTHORIZED error and fires onAuthFailure hooks. You can inspect event.reason to log missing vs invalid credentials.

Basic usage

import { defineServe } from '@hypequery/serve';
import { z } from 'zod';

const apiKeyStrategy = async ({ request }) => {
  const key = request.headers['x-api-key'];
  if (!key || key !== process.env.HQ_API_KEY) {
    return null;
  }
  return { userId: 'service:dashboard', roles: ['internal'] };
};

export const api = defineServe({
  auth: apiKeyStrategy,
  queries: {
    revenue: {
      inputSchema: z.object({ range: z.string() }),
      query: ({ ctx, input }) => ctx.db.table('revenue').where('range', 'eq', input.range).select('*'),
    },
  },
});

Every HTTP request must include x-api-key; otherwise, the runtime returns 401 UNAUTHORIZED. Embedded calls that skip the HTTP layer can still pass auth context manually (see below).

Multiple strategies

Need to support API keys and bearer tokens simultaneously? Provide an array:

const bearerStrategy = async ({ request }) => {
  const header = request.headers.authorization;
  if (!header?.startsWith('Bearer ')) return null;
  const token = header.slice(7);
  const payload = await verifyJwt(token);
  return { userId: payload.sub, scopes: payload.scopes };
};

export const api = defineServe({
  auth: [apiKeyStrategy, bearerStrategy],
  queries: { /* ... */ },
});

Strategies run sequentially until one returns an auth context. If all return null, the request is rejected.

Per-endpoint overrides

Some endpoints might need different auth rules (or none at all). Set auth inside the query definition:

queries: {
  healthcheck: {
    query: async () => ({ ok: true }),
    auth: null, // public endpoint
  },
  adminOnly: {
    auth: async ({ request }) => {
      const token = request.headers['x-admin-token'];
      return token === process.env.ADMIN_TOKEN ? { roles: ['admin'] } : null;
    },
    query: async ({ ctx }) => ctx.db.table('secrets').select('*'),
  },
}

When auth: null, the endpoint bypasses global strategies entirely.

Auth guards

The builder API exposes declarative guards that control who can access each endpoint. These are checked after authentication succeeds, so the distinction is: authentication proves identity, guards enforce authorization.

.requireAuth()

Explicitly mark an endpoint as requiring authentication. When no auth context is present, the request is rejected with 401 UNAUTHORIZED:

const { define, query } = initServe({ context: () => ({ db }) });

const api = define({
  queries: {
    metrics: query
      .requireAuth()
      .query(async ({ ctx }) => ctx.db.table('metrics').select('*')),
  },
});

.requireRole()

Require the user to have at least one of the listed roles. Uses OR semantics—any matching role grants access. Returns 403 FORBIDDEN with details when no role matches:

const api = define({
  queries: {
    adminDashboard: query
      .requireRole('admin', 'super-admin')
      .query(async ({ ctx }) => ctx.db.table('admin_metrics').select('*')),

    editorView: query
      .requireRole('editor')
      .query(async ({ ctx }) => ctx.db.table('content_metrics').select('*')),
  },
});

Roles are checked against ctx.auth.roles. Your auth strategy must populate this field:

const authStrategy = async ({ request }) => {
  const token = request.headers.authorization?.slice(7);
  const payload = await verifyJwt(token);
  return {
    userId: payload.sub,
    roles: payload.roles,   // e.g. ['admin', 'viewer']
    scopes: payload.scopes, // e.g. ['read:metrics']
  };
};

.requireScope()

Require the user to have all listed scopes. Uses AND semantics—every scope must be present. Returns 403 FORBIDDEN when any scope is missing:

const api = define({
  queries: {
    sensitiveExport: query
      .requireScope('read:metrics', 'export:data')
      .query(async ({ ctx }) => ctx.db.table('exports').select('*')),
  },
});

Scopes are checked against ctx.auth.scopes. Combine with .requireRole() when you need both:

adminExport: query
  .requireRole('admin')
  .requireScope('export:data')
  .query(async ({ ctx }) => { /* ... */ }),

.public()

Explicitly opt an endpoint out of authentication, even when global auth strategies are configured. The endpoint still attempts auth (so ctx.auth is populated if credentials happen to be present) but never rejects:

const api = define({
  queries: {
    healthcheck: query
      .public()
      .query(async () => ({ status: 'ok' })),

    protectedMetric: query
      .requireAuth()
      .query(async ({ ctx }) => { /* ... */ }),
  },
});

api.useAuth(myGlobalStrategy); // healthcheck still accessible without credentials

Error responses

Guards return structured errors that clients can branch on:

{
  "error": {
    "type": "FORBIDDEN",
    "message": "Missing required role",
    "details": {
      "reason": "missing_role",
      "required": ["admin"],
      "actual": ["viewer"],
      "endpoint": "/admin-dashboard"
    }
  }
}
GuardHTTP statusError typeSemantics
.requireAuth()401UNAUTHORIZEDNo credentials
.requireRole()403FORBIDDENWrong role (OR)
.requireScope()403FORBIDDENMissing scope (AND)

Security: Controlling error verbosity

By default, auth guards return generic error messages to prevent information leakage about your authorization structure. During development, you can enable verbose mode to see exactly which roles or scopes are missing.

Default (secure mode) - Hides the required and actual arrays:

{
  "error": {
    "type": "FORBIDDEN",
    "message": "Insufficient permissions",
    "details": {
      "reason": "missing_role",
      "endpoint": "/admin-dashboard"
    }
  }
}

To enable verbose error messages during development, set security.verboseAuthErrors to true:

import { defineServe } from '@hypequery/serve';

export const api = defineServe({
  queries,
  security: {
    verboseAuthErrors: true // Show missing roles/scopes (for development)
  }
});

When to use verbose mode:

  • Development: Enable for faster debugging and iteration
  • Internal/private APIs: Enable if you trust all consumers and want better error messages
  • Staging environments: Enable for troubleshooting before production deployment

When to use secure mode (default):

  • Production APIs: Prevents information leakage about your authorization structure
  • Public-facing services: Default recommendation for most applications
  • Multi-tenant SaaS: Prevents tenants from discovering each other’s roles/permissions

The reason field (e.g., "missing_role", "missing_scope") is always included for logging and monitoring purposes, regardless of the verbosity setting. Only the required and actual arrays are hidden in non-verbose mode.

onAuthorizationFailure hook

Track authorization failures separately from authentication failures:

const api = defineServe({
  hooks: {
    onAuthFailure: (event) => {
      // 401 - no credentials or invalid credentials
      logger.warn('auth_failure', { reason: event.reason, path: event.metadata.path });
    },
    onAuthorizationFailure: (event) => {
      // 403 - authenticated but wrong role/scope
      logger.warn('authz_failure', {
        reason: event.reason,
        required: event.required,
        actual: event.actual,
        userId: event.auth?.userId,
        path: event.metadata.path,
      });
    },
  },
  queries: { /* ... */ },
});

Accessing auth in middleware/handlers

ctx.auth contains whichever object your strategy returned:

const logUserMiddleware = async (ctx, next) => {
  console.log('request', ctx.metadata.path, 'user', ctx.auth?.userId ?? 'anonymous');
  return next();
};

export const api = defineServe({
  middlewares: [logUserMiddleware],
  /* ... */
});

You can enrich the context by returning additional fields from the strategy (roles, tenant IDs, plan tier, etc.).

Embedded execution

When calling api.run directly (cron jobs, SSR handlers), pass a synthetic request so your strategies still run:

await api.run('revenue', {
  request: {
    method: 'POST',
    path: '/revenue',
    headers: { 'x-api-key': process.env.HQ_API_KEY },
    query: {},
  },
});

If you already trust the caller (e.g., an internal job), you can skip strategies by calling the underlying query manually or providing a custom context with auth. Just be explicit so you don’t accidentally bypass tenant enforcement.

Type-Safe Authentication

For better type safety and developer experience, you can constrain which roles and scopes are valid in your application using createAuthSystem. This enables TypeScript autocomplete and compile-time checking for role/scope values.

Basic usage

import { createAuthSystem, defineServe, query } from '@hypequery/serve';

// Define your valid roles and scopes up front
const { useAuth, TypedAuth } = createAuthSystem({
  roles: ['admin', 'editor', 'viewer'] as const,
  scopes: ['read:metrics', 'write:metrics', 'delete:metrics'] as const,
});

// Extract the combined auth type
type AppAuth = typeof TypedAuth;

const jwtStrategy: AuthStrategy<AppAuth> = async ({ request }) => {
  const token = request.headers.authorization?.slice(7);
  const payload = await verifyJwt(token);

  return {
    userId: payload.sub,
    roles: payload.roles,  // ✅ Type-checked: must be from the defined list
    scopes: payload.scopes,  // ✅ Type-checked: must be from the defined list
  };
};

export const api = defineServe<AppAuth>({
  auth: useAuth(jwtStrategy),
  queries: {
    adminOnly: query.requireRole('admin').query(async ({ ctx }) => {
      // ✅ TypeScript autocomplete for 'admin'
      // ❌ Compile error if you type 'admn' or 'superadmin'
      return { secret: true };
    }),

    writeData: query.requireScope('write:metrics').query(async ({ ctx }) => {
      // ✅ TypeScript autocomplete for 'write:metrics'
      // ❌ Compile error if you type 'writ:metrics'
      return { success: true };
    }),
  },
});

Benefits

Compile-time safety: Catch typos and invalid role/scope names at build time instead of runtime.

Better DX: IDE autocomplete suggests valid roles and scopes as you type.

Refactoring: If you rename a role (e.g., adminsuperadmin), TypeScript will show you all places that need updating.

Without typed auth (for comparison)

// ❌ No autocomplete, easy to typo
query.requireRole('admn')  // Oops! Runs without error, fails at runtime
query.requireScope('writ:metrics')  // Another typo

With typed auth

// ✅ Autocomplete, typos caught at compile time
query.requireRole('admin')  // ✅ Suggested by IDE
query.requireScope('write:metrics')  // ✅ Suggested by IDE

Using the typed auth context

When you use createAuthSystem, you get a combined TypedAuth type that enforces both role and scope constraints:

const { TypedAuth } = createAuthSystem({
  roles: ['admin', 'editor'] as const,
  scopes: ['read:metrics', 'write:metrics'] as const,
});

type AppAuth = typeof TypedAuth;

// Now you can use this type throughout your app
const middleware: ServeMiddleware<any, any, AppAuth, any> = async (ctx, next) => {
  // ctx.auth.roles is typed as ('admin' | 'editor')[] | undefined
  // ctx.auth.scopes is typed as ('read:metrics' | 'write:metrics')[] | undefined
  return next();
};

Combining constraints

The TypedAuth type combines both role and scope constraints using TypeScript’s utility types:

import type { AuthContextWithRoles, AuthContextWithScopes } from '@hypequery/serve';

type AppRole = 'admin' | 'editor' | 'viewer';
type AppScope = 'read:metrics' | 'write:metrics' | 'delete:metrics';

type AppAuth = AuthContextWithRoles<AppRole> & AuthContextWithScopes<AppScope>;

// This is equivalent to using typeof TypedAuth from createAuthSystem

Troubleshooting

  • Missing headers – ensure your framework forwards headers to the dev server. For example, when proxying through Next.js, copy Authorization/X-API-Key into the request.
  • Edge runtimes – use createFetchHandler or createVercelEdgeHandler so headers/requests stay compatible with the strategies.
  • Logging failures – implement hooks.onAuthFailure to capture repeated failures and alert your team.

With these patterns you can safely protect hypequery endpoints without tying yourself to a specific auth provider. Strategies are just functions—swap tokens, cookies, mTLS metadata, or anything your stack supports.