Add API Key Authentication
Create, manage, and verify scoped machine credentials.
Use the built-in API-key management routes for a signed-in human to create, list, and revoke credentials. Verify those credentials separately at your application boundary.
1. Wire management and verification
The focused ApiKeyHttpApiLive preset exposes POST /auth/api-keys, GET /auth/api-keys, and POST /auth/api-keys/revoke. If these routes must live in your own API, bind ApiKeyHttpOperations.create, .list, and .revoke instead. Both choices require the same domain layers:
import {
ApiKeyManagementLive,
ApiKeysLive,
ApiKeyVerificationLive,
} from "@effect-auth/core/ApiKey";
import * as DrizzleD1ApiKeyStore from "@effect-auth/core/DrizzleD1ApiKeyStore";
import {
ApiKeyHttpApiLive,
ApiKeyHttpOperationsLive,
} from "@effect-auth/core/HttpApi";
import * as Layer from "effect/Layer";
const AppApiKeyStoreLive = DrizzleD1ApiKeyStore.layer();
const ApiKeyDomainLive = Layer.merge(
ApiKeyManagementLive.pipe(Layer.provide(ApiKeysLive)),
ApiKeyVerificationLive.pipe(Layer.provide(ApiKeysLive))
).pipe(
Layer.provide(AppApiKeyStoreLive),
Layer.provide(AppCryptoLive)
);Provide ApiKeyHttpApiLive (or ApiKeyHttpOperationsLive) with ApiKeyDomainLive and your existing session, cookie, HTTP, and security layers. The management operations authenticate the operator from the session and always derive userId server-side; a caller cannot manage another user's keys.
API-key creation validates safe Unix-millisecond timestamps. If expiresAt is supplied, it must be strictly after creation; omission intentionally creates a non-expiring key, so choose that policy explicitly. Verification fails closed for malformed persisted expiry values.
Generate the api-keys migration module, then provide the focused direct Drizzle SQLite, D1, or PostgreSQL ApiKeyStore. The table indexes a unique random prefix for lookup and stores secret_hash, never the secret. ApiKeyStoreMemoryLive is suitable for tests, not production. See Custom Database and HTTP Operations.
AppApiKeyStoreLive and AppCryptoLive above are explicit app-owned
composition boundaries. A Cloudflare deployment normally reuses:
| Alchemy v2 resource | API-key use |
|---|---|
Cloudflare.D1.Database with the auth migrationsDir | Applies the API-key table and provides DrizzleD1Database to the focused API-key store Layer. |
Cloudflare.Worker bindings | Binds that database as env.DB; use Worker Web Crypto for hashing and constant-time comparison. |
| Durable Object rate limiter | Limits management routes and app-owned machine endpoints without storing credentials. |
2. Create, display once, list, and revoke
The public client uses the preset paths and the operator's session cookie:
import { createApiKeyClient } from "@effect-auth/core/Client";
const apiKeys = createApiKeyClient({ baseUrl: "https://auth.example.com" });
const created = await apiKeys.keys.create({
scopes: ["deployments:write"],
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
metadata: { label: "production deployer", applicationId: "app_123" },
});
showSecretOnce(created.secret); // app UI: require immediate copy/download
const { keys } = await apiKeys.keys.list();
await apiKeys.keys.revoke({ keyId: created.key.keyId, reason: "rotated" });create is the only response containing the full secret. Listing returns the key ID, prefix, scopes, timestamps, and metadata, so losing the secret requires creating a replacement. Never log, persist in browser storage, email, or redisplay it. Treat metadata as descriptive only; enforce application ownership from trusted records, not a client-editable label.
3. Protect an API
Read the bearer token and require scopes with the public machine-auth helper:
import { ApiKeyVerification } from "@effect-auth/core/ApiKey";
import * as Audit from "@effect-auth/core/AuditLog";
import { verifyMachineApiKey } from "@effect-auth/core/MachineAuth";
import {
CurrentPrincipal,
PermissionSubject,
} from "@effect-auth/core/Permission";
import { Effect } from "effect";
const deploy = (request: Request) =>
Effect.gen(function* () {
const verification = yield* ApiKeyVerification;
const actor = yield* verifyMachineApiKey({
headers: request.headers,
apiKeyVerification: verification,
requiredScopes: ["deployments:write"],
verificationMetadata: { route: "deploy" },
});
yield* requireApplicationAccess(actor.userId, "app_123"); // app-owned
yield* Audit.apiKeyVerified({
actor: Audit.AuditActor.custom({
actorType: "api-key",
actorReference: actor.keyId,
}),
userId: actor.userId,
keyId: actor.keyId,
scopes: actor.scopes,
});
const principal = PermissionSubject.make("api-key", actor.keyId);
return yield* deployments
.start({ applicationId: "app_123", actor })
.pipe(
Effect.provideService(CurrentPrincipal, CurrentPrincipal.of(principal))
);
});Verification rejects malformed, unknown, revoked, expired, incorrectly hashed,
and under-scoped keys, and updates lastUsedAt. Credential scopes, ownership
checks, and durable permissions are cumulative:
| Result | HTTP response | Boundary |
|---|---|---|
| Missing, malformed, unknown, revoked, expired, or incorrectly hashed key | 401 | Machine-auth boundary |
| Valid key missing a required scope | 403 | verifyMachineApiKey |
| Valid scoped key missing application ownership or permission | 403 | App-owned guard, repeated transactionally for sensitive writes |
| Storage or crypto failure | Sanitized 500 | HTTP error mapper |
The trusted boundary explicitly chooses whether durable permissions belong to the API key, a validated service account, or the owner user. See Security Policies and Protect an API with app-owned guards.
Test and operate
An integration test should create a key, capture its one-time secret, verify a scoped request succeeds, assert a missing scope fails, list without exposing the secret/hash, revoke it, then assert the same bearer token fails. Use makeApiKeyStoreMemory() for unit tests and run the storage contract against the production adapter.
In production, rate-limit both management and application endpoints using durable counters, audit create/list/revoke and accepted/denied use by key ID and prefix, and never record bearer values. Set expirations, grant least-privilege scopes, rotate by creating and deploying a replacement before revoking the old key, protect operator routes with origin/CSRF policy, and alert on unusual failures or use.