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.

operator session                    machine request
      |                                   |
      v                                   v
/auth/api-keys                    Authorization: Bearer <prefix>.<secret>
 create | list | revoke                    |
      |                                   v
      +---- durable ApiKeyStore <---- prefix lookup -> hash comparison
                                               |
                                               v
                                  scope + app ownership checks -> handler

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 {
  ApiKeyHttpApiLive,
  ApiKeyHttpOperationsLive
} from "@effect-auth/core/HttpApi";
import { Layer } from "effect";

const ApiKeyDomainLive = Layer.merge(
  ApiKeyManagementLive.pipe(Layer.provide(ApiKeysLive)),
  ApiKeyVerificationLive.pipe(Layer.provide(ApiKeysLive))
).pipe(
  Layer.provide(AppApiKeyStoreLive), // your durable ApiKeyStore layer
  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.

Apply migrations/0008_auth_api_key.sql when using the SQLite storage stack, or implement the public ApiKeyStore contract in another database. The table indexes a unique random prefix for lookup and stores secret_hash, never the secret. ApiKeyStoreMemoryLive is suitable for tests, not production. See Storage and HTTP Operations.

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 { verifyMachineApiKey } from "@effect-auth/core/MachineAuth";
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
  return yield* deployments.start({ applicationId: "app_123", actor });
});

Verification rejects malformed, unknown, revoked, expired, incorrectly hashed, and under-scoped keys, and updates lastUsedAt. Map missing/invalid credentials to 401, missing scope or ownership to 403, and infrastructure failures to a sanitized 500. The final ownership check belongs to your application and should be repeated transactionally for sensitive writes. 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