effect-auth

Protect an API Endpoint

Authenticate one endpoint with a session cookie, JWT bearer token, or API key.

Protecting POST /reports/:id/run has two distinct steps:

Authentication and authorization boundary

effect-auth does not expose one middleware that accepts all three credential types. Choose one mode per route, verify it at your HTTP boundary, and translate its result into a trusted CurrentPrincipal plus any richer app-owned principal your policy needs. Do not let the presence of any valid credential imply permission.

Choose a mode

ModeBest forVerification resultRevocation / state
Session cookieBrowser applicationValidatedSession, including currentSession and actorServer-side session row; immediate revocation
JWT bearerService or delegated API accessVerified header, claims, and keyUsually valid until expiry; add introspection/revocation if required
API keyAutomation and integrationsApiKeyInfo with owner and scopesServer-side key row; expiry and revocation checked
Deployment concernAlchemy v2 / Cloudflare choice
HTTP boundaryOne Cloudflare.Worker or Cloudflare.Website.Vite handler
Stateful modesD1 bound as DB for sessions and API keys
JWT modePublic verification JWK binding only; no private key in the API Worker
ErrorsWorker maps invalid credentials to 401, denied policy to 403, and dependency failures to 500

Define the protected operation

This code is app-owned. The adapters below supply AppPrincipal; the guard checks current application data before the domain operation runs.

import { Context, Effect } from "effect";
import * as Guard from "@effect-auth/core/Guard";
import type { UserId } from "@effect-auth/core/Identifiers";

type AppPrincipalShape = {
  readonly userId: UserId;
  readonly scopes: readonly string[];
};
class AppPrincipal extends Context.Tag("app/AppPrincipal")<
  AppPrincipal,
  AppPrincipalShape
>() {}

const runReport = (reportId: string) =>
  Effect.gen(function* () {
    const principal = yield* AppPrincipal;
    return yield* Reports.runForUser(reportId, principal.userId); // app-owned
  }).pipe(
    Guard.require(requireReportAccess(reportId)) // app-owned Effect; deny with 403
  );

Guard.require only sequences Effects. It neither authenticates nor defines policy. CurrentPrincipal is the stable subject consumed by permission policy. For session-specific code, middleware may additionally provide CurrentSession and CurrentActor: CurrentSession includes assurance and expiry fields; CurrentActor contains only userId and sessionId.

Add exactly one boundary adapter

These are minimal app-owned Cloudflare Worker boundary adapters. Header parsing, typed public errors, and framework response conversion remain yours.

import { Effect, Option } from "effect";
import {
  CurrentActor,
  CurrentSession,
  SessionCookie,
  Sessions,
} from "@effect-auth/core/Sessions";
import {
  CurrentPrincipal,
  PermissionSubject,
} from "@effect-auth/core/Permission";

const withSession = (
  request: Request,
  operation: Effect.Effect<unknown, unknown, AppPrincipal>
) =>
  Effect.gen(function* () {
    const cookies = yield* SessionCookie;
    const sessions = yield* Sessions;
    const token = yield* cookies.read(request);
    if (Option.isNone(token)) return yield* unauthenticated; // app-owned 401
    const validated = yield* sessions.validate(token.value);
    return yield* operation.pipe(
      Effect.provideService(
        CurrentSession,
        CurrentSession.make(validated.currentSession)
      ),
      Effect.provideService(CurrentActor, CurrentActor.of(validated.actor)),
      Effect.provideService(
        CurrentPrincipal,
        CurrentPrincipal.of(PermissionSubject.user(validated.actor.userId))
      ),
      Effect.provideService(AppPrincipal, {
        userId: validated.actor.userId,
        scopes: [],
      })
    );
  });

Map expected missing, malformed, expired, or revoked sessions to the same 401; treat storage failures as 500. For built-in auth routes, prefer the session HTTP operations, which already own cookie behavior. See Sessions.

JWT bearer

import { Redacted } from "effect";
import { JwtVerifier } from "@effect-auth/core/Jwt";

const verified =
  yield *
  (yield * JwtVerifier).verify({
    token: Redacted.make(readBearer(request)), // app-owned strict parser
    issuer: "https://issuer.example",
    audience: "reports-api",
  });
if (!verified.valid || typeof verified.claims.sub !== "string")
  return yield * unauthenticated;

const principal = claimsToPrincipal(verified.claims); // app-owned validation/mapping
const subject = jwtClaimsToPermissionSubject(verified.claims); // issuer-qualified
return (
  yield *
  operation.pipe(
    Effect.provideService(CurrentPrincipal, CurrentPrincipal.of(subject)),
    Effect.provideService(AppPrincipal, principal)
  )
);

JwtVerifier.verify checks signature, time, issuer, and audience and returns { valid: false, reason } for credential rejection. Map a failed verifier Effect, such as key-store failure, to 500 rather than 401. Validate every application claim and constrain issuer, audience, and algorithms through configured keys. See the JWT recipe.

API key

import { Redacted } from "effect";
import { ApiKeyVerification } from "@effect-auth/core/ApiKey";

const result =
  yield *
  (yield * ApiKeyVerification).verify({
    secret: Redacted.make(readApiKey(request)), // app-owned header parser
  });
if (!result.valid || result.key === undefined) return yield * unauthenticated;

const subject = resolveApiKeyPermissionSubject(result.key); // app-owned choice
return (
  yield *
  operation.pipe(
    Effect.provideService(CurrentPrincipal, CurrentPrincipal.of(subject)),
    Effect.provideService(AppPrincipal, {
      userId: result.key.userId,
      scopes: result.key.scopes,
    })
  )
);

Verification parses the prefix, loads the row, rejects revoked/expired keys, compares the hash safely, and records last use. The app explicitly chooses whether the principal is the API key, a validated service account, or its owner. Credential scopes and durable permissions are an intersection; neither bypasses the other. See the API key recipe.

Test and secure the boundary

  1. Call without a credential, with malformed input, and with an invalid credential; expect the same sanitized 401.
  2. Call with a valid identity lacking report access; expect 403 (or deliberate 404) and no domain write.
  3. Call with access; expect success. Then revoke/expire the session or API key and retry. Test JWT expiry, wrong issuer, and wrong audience.
  4. Never log cookies, bearer tokens, API keys, or raw verification failures. Use TLS, rate-limit failures, cap header sizes, and reject routes receiving multiple credential modes rather than guessing precedence.

For richer policy composition, continue with Permissions and Roles and App-owned Guards.

On this page