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:
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
| Mode | Best for | Verification result | Revocation / state |
|---|---|---|---|
| Session cookie | Browser application | ValidatedSession, including currentSession and actor | Server-side session row; immediate revocation |
| JWT bearer | Service or delegated API access | Verified header, claims, and key | Usually valid until expiry; add introspection/revocation if required |
| API key | Automation and integrations | ApiKeyInfo with owner and scopes | Server-side key row; expiry and revocation checked |
| Deployment concern | Alchemy v2 / Cloudflare choice |
|---|---|
| HTTP boundary | One Cloudflare.Worker or Cloudflare.Website.Vite handler |
| Stateful modes | D1 bound as DB for sessions and API keys |
| JWT mode | Public verification JWK binding only; no private key in the API Worker |
| Errors | Worker 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.
Session cookie
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
- Call without a credential, with malformed input, and with an invalid credential; expect the same sanitized
401. - Call with a valid identity lacking report access; expect
403(or deliberate404) and no domain write. - Call with access; expect success. Then revoke/expire the session or API key and retry. Test JWT expiry, wrong issuer, and wrong audience.
- 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.