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:

request -> authenticate credential -> AppPrincipal -> authorize report -> run
              401                         403          domain errors

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 an application-owned principal. 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

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. For session-only application code, middleware may instead provide the real CurrentSession and CurrentActor services: CurrentSession includes assurance and expiry fields; CurrentActor contains only userId and sessionId.

Add exactly one boundary adapter

These are minimal app-owned boundary sketches. 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";

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(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
return yield * operation.pipe(Effect.provideService(AppPrincipal, principal));

JwtVerifier.verify checks signature, time, issuer, and audience and returns { valid: false, reason } for credential rejection. Validate every application claim and constrain accepted issuer, audience, and algorithms through your 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;

return (
  yield *
  operation.pipe(
    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 guard must still enforce the required scope and resource access. 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 App-owned Guards.

On this page