> hypequery

Authentication

Secure hypequery endpoints with auth strategies, type-safe auth guards, and role-based access control

Authentication

hypequery provides a complete authentication and authorization system. You define auth strategies that extract user identity from requests, then use type-safe guards to control who can access each endpoint.

Core Concepts

  • Auth strategies run first, extracting identity from requests (API keys, JWT tokens, cookies, etc.) and returning an auth context with userId, roles, and scopes
  • Auth guards (requireAuth(), requireRole(), requireScope()) control endpoint access after authentication succeeds
  • Type-safe auth with createAuthSystem enables autocomplete and compile-time checking for roles and scopes
  • Auth context is injected into ctx.auth and available in queries, middleware, and tenant helpers
  • Failures return structured errors: 401 UNAUTHORIZED for missing auth, 403 FORBIDDEN for wrong roles/scopes

Quick Example

Here's a complete example showing type-safe auth with role and scope-based access control:

import { initServe, createAuthSystem } from '@hypequery/serve';
import { z } from 'zod';

// ============================================================
// 1. Define your valid roles and scopes
// ============================================================

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

type AppAuth = typeof TypedAuth;
type AppContext = { db: any };

// ============================================================
// 2. Create an auth strategy
// ============================================================

const authStrategy = async ({ request }): Promise<AppAuth | null> => {
  const token = request.headers?.['x-auth-token'];
  if (!token) return null;

  // Verify JWT token
  const payload = await verifyJwt(token);

  return {
    userId: payload.sub,
    userName: payload.name,
    roles: payload.roles,  // e.g., ['admin', 'viewer']
    scopes: payload.scopes, // e.g., ['read:data', 'write:data']
  };
};

// ============================================================
// 3. Define your API with auth guards
// ============================================================

const { define, query } = initServe<AppContext, AppAuth>({
  context: () => ({ db: createDbConnection() }),
});

export const api = define({
  auth: useAuth(authStrategy),

  queries: {
    // Public endpoint - anyone can access
    public: query
      .public()
      .describe('Public health check')
      .query(async () => ({
        status: 'ok',
        message: 'Anyone can access this',
      })),

    // Require authentication
    profile: query
      .requireAuth()
      .describe('Get user profile')
      .query(async ({ ctx }) => ({
        userId: ctx.auth?.userId,
        userName: ctx.auth?.userName,
        roles: ctx.auth?.roles,
      })),

    // Require specific role (OR semantics - any listed role grants access)
    adminOnly: query
      .requireRole('admin', 'super-admin')
      .describe('Admin-only endpoint')
      .query(async ({ ctx }) => ({
        message: 'Admin data',
        data: await ctx.db.table('admin_data').select('*'),
      })),

    // Require specific scope (AND semantics - all listed scopes required)
    writeData: query
      .requireScope('write:data')
      .describe('Write data endpoint')
      .query(async ({ ctx, input }) => ({
        success: true,
        data: await ctx.db.table('data').insert(input),
      })),

    // Combine guards - needs both role AND scope
    superAdmin: query
      .requireRole('admin')
      .requireScope('delete:data')
      .describe('Super admin operation')
      .query(async ({ ctx }) => ({
        message: 'Super admin operation',
      })),
  },
});

Auth Guards

Guards are declarative methods on the query builder that control authorization. They're checked after authentication succeeds.

.public()

Opt an endpoint out of authentication. The endpoint still attempts auth (so ctx.auth is populated if credentials are present) but never rejects missing credentials.

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

.requireAuth()

Require authentication. Returns 401 UNAUTHORIZED when no auth context is present.

profile: query
  .requireAuth()
  .query(async ({ ctx }) => ({
    userId: ctx.auth?.userId,
  })),

.requireRole(...roles)

Require the user to have at least one of the listed roles (OR semantics). Returns 403 FORBIDDEN when no role matches.

adminDashboard: query
  .requireRole('admin', 'super-admin')
  .query(async ({ ctx }) => { /* ... */ }),

.requireScope(...scopes)

Require the user to have all listed scopes (AND semantics). Returns 403 FORBIDDEN when any scope is missing.

sensitiveExport: query
  .requireScope('read:data', 'export:data')
  .query(async ({ ctx }) => { /* ... */ }),

Combining Guards

Chain multiple guards to enforce complex requirements:

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

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)

Type-Safe Auth with createAuthSystem

createAuthSystem enables TypeScript autocomplete and compile-time checking for role and scope values.

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

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

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

Benefits

  • Compile-time safety: Catch typos like 'admn' or 'superadmin' at build time
  • IDE autocomplete: Valid roles and scopes are suggested as you type
  • Refactoring: Rename roles and TypeScript shows all places that need updating

Using the Typed Auth Type

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 defined list
    scopes: payload.scopes, // ✅ Type-checked: must be from defined list
  };
};

const { define, query } = initServe<AppContext, AppAuth>({
  auth: useAuth(jwtStrategy),
  context: () => ({ db }),
});

export const api = define({
  queries: {
    // ✅ TypeScript autocomplete for 'admin'
    // ❌ Compile error if you typo 'admn' or 'superadmin'
    adminOnly: query
      .requireRole('admin')
      .query(async ({ ctx }) => ({ secret: true })),
  },
});

Auth Strategies

Strategies extract auth context from incoming requests. Return null for failed authentication.

API Key Strategy

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'],
    scopes: ['read:all'],
  };
};

JWT Bearer Token Strategy

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,
    roles: payload.roles,
    scopes: payload.scopes,
  };
};

Multiple Strategies

Provide an array to support multiple auth methods. Strategies run sequentially until one succeeds.

const { define } = initServe({
  auth: [apiKeyStrategy, bearerStrategy],
  context: () => ({ db }),
});

Global Auth Configuration

Set auth globally in initServe or define:

const { define, query } = initServe<AppContext, AppAuth>({
  auth: useAuth(authStrategy),
  context: () => ({ db }),
});

export const api = define({
  // All queries inherit auth strategy
  queries: {
    // This can use .public() to bypass global auth
    healthcheck: query.public().query(async () => ({ ok: true })),
  },
});

Accessing Auth in Queries

Access auth context via ctx.auth:

profile: query
  .requireAuth()
  .query(async ({ ctx }) => ({
    userId: ctx.auth?.userId,
    roles: ctx.auth?.roles,
    scopes: ctx.auth?.scopes,
  })),

Accessing Auth in Middleware

const logUserMiddleware = async (ctx, next) => {
  console.log('User:', ctx.auth?.userId ?? 'anonymous');
  console.log('Roles:', ctx.auth?.roles ?? []);
  return next();
};

const { define } = initServe({
  middlewares: [logUserMiddleware],
  auth: authStrategy,
  context: () => ({ db }),
});

Embedded Execution

When calling api.run() directly (cron jobs, SSR), pass a synthetic request:

// With auth
const result = await api.run('profile', {
  request: {
    method: 'POST',
    path: '/profile',
    headers: { 'x-auth-token': 'admin-token' },
    query: {},
  },
});

Bypassing auth in internal calls

For trusted internal calls, you can provide context directly to skip auth:

// Skip auth by providing context
const result = await api.run('adminOnly', {
  context: {
    db: yourDbConnection,
    auth: { userId: 'system', roles: ['admin'] },
  },
  input: {},
});

Framework Integration

Next.js App Router

// app/api/analytics/[...path]/route.ts
import { api } from '@/analytics/queries';
import { createFetchHandler } from '@hypequery/serve';

const handler = createFetchHandler(api.handler);

export const runtime = 'nodejs';
export const GET = handler;
export const POST = handler;

Hono

// src/app.ts
import { api } from './analytics/queries';
import { createFetchHandler } from '@hypequery/serve';
import { Hono } from 'hono';

const app = new Hono();
const hypequery = createFetchHandler(api.handler);

app.all('/api/*', (c) => hypequery(c.req.raw));

Security: Controlling Error Verbosity

By default, auth guards hide required and actual arrays to prevent information leakage. Enable verbose mode for development:

const { define } = initServe({
  security: {
    verboseAuthErrors: true, // Show missing roles/scopes (development only!)
  },
  auth: authStrategy,
  context: () => ({ db }),
});

When to use verbose mode:

  • ✅ Development and debugging
  • ✅ Internal/private APIs with trusted consumers
  • ❌ Production APIs (prevents information leakage)

Hooks for Monitoring

Track authentication and authorization failures separately:

const { define } = initServe({
  hooks: {
    onAuthFailure: async ({ request, reason }) => {
      // 401 - No credentials or invalid credentials
      logger.warn('auth_failure', { reason, ip: request.headers['x-forwarded-for'] });
    },
    onAuthorizationFailure: async ({ auth, required, actual, reason }) => {
      // 403 - Authenticated but wrong role/scope
      logger.warn('authz_failure', {
        userId: auth?.userId,
        reason,
        required,
        actual,
      });
    },
  },
  auth: authStrategy,
  context: () => ({ db }),
});

Troubleshooting

Missing headers – Ensure your framework forwards headers to the hypequery handler. For Next.js or Vercel, copy Authorization/X-API-Key into the request.

Edge runtimes – Use createFetchHandler or createVercelEdgeHandler so headers/requests stay compatible with auth strategies.

Type errors – Use createAuthSystem to get autocomplete and catch typos at compile time.

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.

On this page