> hypequery

Authentication

Add authentication with API keys, typed auth guards, and shared auth context.

Authentication

Authentication in hypequery starts at the runtime layer. You attach auth strategies to initServe(...) or serve({ ... }), and the resolved auth object is made available as ctx.auth inside your query definitions.

Choose a strategy

Most apps don't need to hand-roll credential parsing. Pick the built-in strategy that matches how your runtime is deployed:

DeploymentStrategyUse when
Same appfromContext(...)hypequery runs inside an app that already authenticates the request
Cross-origin / embeddedcreateJwtStrategy(...)a separate client sends a JWT (your own HS256 secret or a provider's JWKS)
Signed embeddingcreateAnalyticsTokenIssuer(...)you mint short-lived analytics tokens server-side
Custom systemscreateApiKeyStrategy(...) / createBearerTokenStrategy(...)you need bespoke credential handling

Whichever you choose, the resolved auth object is exposed as ctx.auth. When auth is configured, endpoints require authentication by default — mark exceptions with requiresAuth: false (or query.public()).

If hypequery runs inside an app that already authenticates requests, reuse that session instead of validating credentials again. fromContext hands you the request so you can read the user your framework already resolved.

import { fromContext, initServe } from '@hypequery/serve';
import { db } from './client';
// Your app's existing session helper — reads the cookie/token and returns the user.
import { getUserFromRequest } from './auth';

const { query, serve } = initServe({
  context: () => ({ db }),
  auth: fromContext(({ request }) => {
    const user = getUserFromRequest(request.raw);
    return user
      ? { userId: user.id, tenantId: user.orgId, roles: user.roles }
      : null;
  }),
  basePath: '/api/analytics',
});

const tenantUsers = query({
  requiresAuth: true,
  query: ({ ctx }) =>
    ctx.db
      .table('users')
      .select(['id', 'email', 'last_seen_at'])
      .where('tenant_id', 'eq', ctx.auth!.tenantId)
      .orderBy('last_seen_at', 'DESC')
      .limit(50)
      .execute(),
});

export const api = serve({
  queries: { tenantUsers },
});

request.raw is the underlying framework request (the Node/Fetch object), so you can call whatever session helper you already use.

Cross-origin auth with createJwtStrategy

When a separate client calls your runtime, verify a JWT bearer token. Use a shared secret for tokens you mint yourself (HS256), or jwksUri for tokens from a provider like Auth0, Clerk, or Cognito (RS256).

import { createJwtStrategy, initServe } from '@hypequery/serve';
import { db } from './client';

// Tokens you mint yourself (HS256).
const secretAuth = createJwtStrategy({
  secret: process.env.HYPEQUERY_AUTH_SECRET!,
  issuer: 'https://your-app.example.com',
  audience: 'hypequery-analytics',
});

// Tokens from a provider via JWKS (RS256).
const providerAuth = createJwtStrategy({
  jwksUri: 'https://example.auth0.com/.well-known/jwks.json',
  issuer: 'https://example.auth0.com/',
  audience: 'https://api.example.com',
});

const { query, serve } = initServe({
  context: () => ({ db }),
  auth: secretAuth,
});

By default the verified claims are mapped to ctx.auth as sub → userId, org_id → tenantId, roles → roles, and scope/scopes → scopes. Override that with mapClaims(payload, request) when your tokens use different claim names.

Signed embedding with createAnalyticsTokenIssuer

For embedded dashboards, mint short-lived analytics tokens on your server and verify them with createJwtStrategy({ secret }).

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

const issueAnalyticsToken = createAnalyticsTokenIssuer({
  secret: process.env.HYPEQUERY_AUTH_SECRET!,
  expiresIn: '15m',
  issuer: 'https://your-app.example.com',
  audience: 'hypequery-analytics',
});

// In an authenticated route on your own server:
app.get('/api/analytics/token', requireUser, async (req, res) => {
  res.json({
    token: await issueAnalyticsToken({
      userId: req.user.id,
      tenantId: req.user.orgId,
      roles: req.user.roles,
    }),
  });
});

Custom strategies

When you need bespoke credential handling, createApiKeyStrategy and createBearerTokenStrategy give you a validate hook that returns your auth object or null.

API key

import { createApiKeyStrategy, initServe } from '@hypequery/serve';
import { db } from './client';

const apiKeyAuth = createApiKeyStrategy({
  header: 'x-api-key',
  validate: async (key) => {
    const account = await findApiKey(key);
    if (!account) return null;

    return {
      userId: account.userId,
      tenantId: account.tenantId,
      role: account.role,
    };
  },
});

const { query, serve } = initServe({
  context: () => ({ db }),
  auth: apiKeyAuth,
  basePath: '/api/analytics',
});

Bearer token

import { createBearerTokenStrategy, initServe } from '@hypequery/serve';
import { db } from './client';

const bearerAuth = createBearerTokenStrategy({
  validate: async (token) => {
    const payload = await verifyJwt(token);
    return payload
      ? {
          userId: payload.sub,
          email: payload.email,
          tenantId: payload.tenantId,
        }
      : null;
  },
});

const { query, serve } = initServe({
  context: () => ({ db }),
  auth: bearerAuth,
});

Prefer createJwtStrategy over a hand-written bearer validate when you're verifying standard JWTs — it handles signature verification, issuer/audience checks, and claim mapping for you.

Where auth lives

  • attach auth globally in initServe(...) or serve({ ... })
  • read the resolved auth object from ctx.auth
  • combine auth with Multi-Tenancy when tenant identity comes from credentials

Per-query auth in query({ ... })

Use object-style auth fields when a reusable query definition should enforce access rules directly.

import { createAuthSystem, initServe } from '@hypequery/serve';
import { db } from './client';

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

type AppAuth = typeof TypedAuth;

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

  const payload = await verifyJwt(token);

  return {
    userId: payload.sub,
    roles: payload.roles,
    scopes: payload.scopes,
  };
};

const { query, serve } = initServe({
  context: () => ({ db }),
  auth: useAuth(authStrategy),
});

const adminMetrics = query({
  description: 'Admin-only revenue metrics',
  requiredRoles: ['admin'],
  requiredScopes: ['read:data'],
  query: async ({ ctx }) =>
    ctx.db
      .table('metrics')
      .select(['name', 'value', 'updated_at'])
      .orderBy('updated_at', 'DESC')
      .limit(20)
      .execute(),
});

const health = query({
  requiresAuth: false,
  query: async () => ({ ok: true }),
});

export const api = serve({
  queries: { adminMetrics, health },
});

Semantics:

  • requiresAuth: false makes a query public
  • requiresAuth: true requires an authenticated user
  • requiredRoles: ['admin', 'editor'] uses OR semantics
  • requiredScopes: ['read:data', 'write:data'] uses AND semantics
  • requiredRoles or requiredScopes imply auth automatically

Typed authorization with createAuthSystem

Use createAuthSystem(...) when you want compile-time safety for roles and scopes.

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

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

type AppAuth = typeof TypedAuth;

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

  const payload = await verifyJwt(token);

  return {
    userId: payload.sub,
    roles: payload.roles,
    scopes: payload.scopes,
  };
};

const { query, serve } = initServe({
  context: () => ({ db }),
  auth: useAuth(authStrategy),
});

const adminMetrics = query({
  requiredRoles: ['admin'],
  query: async ({ ctx }) =>
    ctx.db
      .table('metrics')
      .select(['name', 'value'])
      .orderBy('value', 'DESC')
      .limit(10)
      .execute(),
});

This gives you:

  • autocomplete for valid roles and scopes
  • compile-time checking for requiredRoles and requiredScopes
  • a typed ctx.auth shape across auth strategies, queries, and middleware

Notes

Headers are plain objects

Auth strategies receive a ServeRequest whose headers are plain objects, not Fetch Headers. Use request.headers.authorization or request.headers['x-api-key'].

Guard methods

The query builder-compatible auth guards are still current and supported:

  • .requireAuth()
  • .requireRole(...)
  • .requireScope(...)
  • .public()

Use object-style auth fields by default on query({ ... }). Use the chainable guard methods when you prefer the builder-style query surface or need backwards compatibility with existing guard-heavy definitions.

Auth on semantic endpoints

Auto-generated metrics and datasets endpoints are protected the same way as queries, but you declare the requirements on the per-entry config object instead of inside a query({ ... }) definition. Each entry accepts auth, requiresAuth, requiredRoles, and requiredScopes.

import { initServe } from '@hypequery/serve';
import { db } from './client';
import { Orders, revenue } from './datasets/orders';

const { serve } = initServe({
  context: () => ({ db }),
  auth: authStrategy,
});

export const api = serve({
  queryBuilder: db,
  metrics: {
    // Shorthand: inherits the global auth strategy with no extra requirements.
    revenue,
  },
  datasets: {
    orders: {
      dataset: Orders,
      requiredRoles: ['analytics'],
      requiredScopes: ['read:data'],
    },
  },
});

The semantics match query({ ... }):

  • requiredRoles uses OR semantics (any listed role grants access)
  • requiredScopes uses AND semantics (all listed scopes required)
  • declaring either one implies authentication
  • auth on an entry adds a local strategy; omitting it or setting auth: null still inherits global auth
  • requiresAuth: false makes an entry public unless it declares required roles or scopes
  • requiresAuth: true requires authentication even when no local or global strategy is configured

If you previously used auth: null as a public override on a dataset or metric, replace it with requiresAuth: false when upgrading.

Metrics use the same shape via { metric, auth, requiresAuth, requiredRoles, requiredScopes }. See Serve integration for the full set of per-entry options.

Trusted principals for in-process hosts

Some hosts verify the caller before Serve ever sees the request — a Cloud gateway that checks its own credential, or a worker running a deployment bundle. Those hosts already hold a verified principal and have no auth strategy of their own to re-run. api.execute() (and its client() / run() aliases) accept a trustedAuth option for exactly that case:

// Only inside a host that has already verified the caller itself.
const rows = await api.execute('revenue', {
  input: { month: '2026-07' },
  trustedAuth: {
    userId: verified.sub,
    tenantId: verified.org,
    roles: verified.roles,
    scopes: verified.scopes,
  },
});

Supplying trustedAuth skips only credential parsing — the configured auth strategies do not run. Everything else still applies to the principal you pass:

  • requiredRoles and requiredScopes are enforced, so an under-privileged principal gets a FORBIDDEN error just as an HTTP caller would
  • tenant.extract runs against it, and a required tenant that cannot be extracted is rejected
  • the context factory receives it as auth, and input/output validation, middleware, and lifecycle hooks are unchanged
  • responses stay cache-control: no-store

This is a trust boundary

trustedAuth is an assertion that your host authenticated the caller. Never populate it from request headers, a request body, a query string, or any other value the caller controls — doing so lets a client name its own principal. It is unreachable from the HTTP handler by design; only in-process callers can set it. Pass null or omit it to fall through to the configured strategies.

Because the principal is what authorization ran against, the pipeline owns ctx.auth and ctx.tenantId. A caller-supplied context that tries to set either is rejected with a VALIDATION_ERROR rather than silently overwriting them:

// Rejected: `context` may not shadow the authenticated principal.
await api.execute('revenue', {
  trustedAuth: principal,
  context: { auth: { userId: 'someone-else' } },
});

Deployment runtime artifacts built by hypequery deployment forward trustedAuth through to api.execute(), so a worker hosting a bundle enforces the API's declared permissions and tenancy without reinterpreting its own gateway credential as customer auth.

See Also

On this page