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.