API Key Auth — vyuh_server_plugin_auth_apikey
AuthPlugin that contributes a single ApiKeyStrategy. The strategy extracts a key from the X-API-Key header (or a configurable query param), calls your lookup callback, and maps the result to an Actor.
The right shape for partner integrations, webhook deliveries, machine- to-machine traffic, and anything where issuing a JWT is overkill.
Install
dependencies:
vyuh_server_plugin_auth_apikey:
hosted: https://pub.vyuh.tech
version: ^0.1.0Basic Wiring
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_auth_apikey/vyuh_server_plugin_auth_apikey.dart';
Future<Actor?> lookupApiKey(String key) async {
final rows = await vyuh.db.execute(
'''
SELECT ak.partner_id, ak.tenant_id, ak.scopes
FROM api_keys ak
WHERE ak.key_hash = digest(@key, 'sha256')
AND ak.revoked_at IS NULL
AND (ak.expires_at IS NULL OR ak.expires_at > NOW())
''',
parameters: {'key': key},
);
if (rows.isEmpty) return null; // key not found / revoked / expired
final row = rows.rowAsMap(0);
return ServiceActor(
id: row['partner_id'] as String,
serviceName: row['partner_name'] as String? ?? 'partner',
tenantId: row['tenant_id'] as String?,
roles: Set<String>.from(row['scopes'] as List? ?? const []),
);
}
final runtime = await VyuhServer.bootstrap(
plugins: [
ApiKeyAuthPlugin(lookup: lookupApiKey),
],
);Lookup Semantics
The lookup callback follows the auth-strategy contract:
| Return | Meaning |
|---|---|
Actor | Key valid; the request is authenticated |
null | Key not recognized; the chain continues to the next strategy |
Throws AuthFailed | Key present but invalid (revoked, expired, malformed); the chain stops |
Use the distinction:
- A request without an
X-API-Keyheader getsnull(no credentials presented). If you stackJwtAuthPluginfirst, the request can still authenticate via JWT. - A request with an
X-API-Keythat's missing from your store also getsnull— same reason. The next strategy might recognize the bearer token. - A request with an
X-API-Keythat exists but was revoked should throwAuthFailed— the credential was deliberately invalidated.
Custom Header / Query Param
The defaults are X-API-Key (header) and api_key (query). Customize per-deployment:
ApiKeyAuthPlugin(
lookup: lookupApiKey,
headerName: 'authorization', // accept "Authorization: <key>"
queryParam: 'token',
);Hash-Storage Pattern
Don't store raw keys. Hash on insert; hash again on lookup. The example above uses Postgres' digest() from pgcrypto:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE api_keys (
id UUID PRIMARY KEY,
partner_id TEXT NOT NULL,
tenant_id TEXT,
key_hash BYTEA NOT NULL UNIQUE,
scopes JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);When issuing a new key:
final raw = generateRandomKey(); // 32+ bytes, hex or base64url
await vyuh.db.execute(
'''
INSERT INTO api_keys (id, partner_id, tenant_id, key_hash, scopes)
VALUES (@id, @partner, @tenant, digest(@key, 'sha256'), @scopes::jsonb)
''',
parameters: {
'id': Uuid.v4(),
'partner': partnerId,
'tenant': tenantId,
'key': raw,
'scopes': jsonEncode(scopes),
},
);
// Return `raw` to the caller exactly once. You can never recover it.Caching for Hot Paths
Hot lookup paths (every webhook delivery) benefit from a short cache:
final _cache = <String, _Entry>{};
Future<Actor?> lookupApiKey(String key) async {
final cached = _cache[key];
if (cached != null && cached.fetchedAt.isAfter(_oldestValid)) {
return cached.actor;
}
final actor = await _dbLookup(key);
_cache[key] = _Entry(actor, DateTime.now());
return actor;
}For multi-instance deployments, back the cache with Redis or use a short Postgres prepared statement to amortize the round-trip.
Stacking Strategies
A common pattern: user-facing routes use JWTs; partner routes use API keys. Stack both — the strategies live on different paths through their respective RouteModule.protectedPaths:
plugins: [
JwtAuthPlugin(verifier: verifyToken),
ApiKeyAuthPlugin(lookup: lookupApiKey),
],
features: [
// user-facing routes
FeatureDescriptor(
name: 'app',
extend: (scope) => userRoutes.applyTo(scope),
),
// partner webhook receivers
FeatureDescriptor(
name: 'partners',
extend: (scope) => partnerRoutes.applyTo(scope),
),
],Both strategies sit in the chain. A user request presents Authorization: Bearer <jwt>; JWT strategy returns an Actor, API key strategy never sees it. A webhook presents X-API-Key: <key>; JWT strategy returns null, API key strategy resolves it.
Where to Go Next
- JWT Auth — user-facing auth
- Middleware & Context — where auth sits
- Writing a Plugin — write a custom strategy (mTLS, session cookie, mutual auth)