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:
| Deployment | Strategy | Use when |
|---|---|---|
| Same app | fromContext(...) | hypequery runs inside an app that already authenticates the request |
| Cross-origin / embedded | createJwtStrategy(...) | a separate client sends a JWT (your own HS256 secret or a provider's JWKS) |
| Signed embedding | createAnalyticsTokenIssuer(...) | you mint short-lived analytics tokens server-side |
| Custom systems | createApiKeyStrategy(...) / 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()).
Same-app auth with fromContext (recommended)
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(...)orserve({ ... }) - 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: falsemakes a query publicrequiresAuth: truerequires an authenticated userrequiredRoles: ['admin', 'editor']uses OR semanticsrequiredScopes: ['read:data', 'write:data']uses AND semanticsrequiredRolesorrequiredScopesimply 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
requiredRolesandrequiredScopes - a typed
ctx.authshape 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({ ... }):
requiredRolesuses OR semantics (any listed role grants access)requiredScopesuses AND semantics (all listed scopes required)- declaring either one implies authentication
authon an entry adds a local strategy; omitting it or settingauth: nullstill inherits global authrequiresAuth: falsemakes an entry public unless it declares required roles or scopesrequiresAuth: truerequires 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:
requiredRolesandrequiredScopesare enforced, so an under-privileged principal gets aFORBIDDENerror just as an HTTP caller wouldtenant.extractruns 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.