effect-auth

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.

Human management and machine verification share durable state

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 resourceAPI-key use
Cloudflare.D1.Database with the auth migrationsDirApplies the API-key table and provides DrizzleD1Database to the focused API-key store Layer.
Cloudflare.Worker bindingsBinds that database as env.DB; use Worker Web Crypto for hashing and constant-time comparison.
Durable Object rate limiterLimits 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:

ResultHTTP responseBoundary
Missing, malformed, unknown, revoked, expired, or incorrectly hashed key401Machine-auth boundary
Valid key missing a required scope403verifyMachineApiKey
Valid scoped key missing application ownership or permission403App-owned guard, repeated transactionally for sensitive writes
Storage or crypto failureSanitized 500HTTP 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.

On this page