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 errorseffect-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
| 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 |
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.
Session cookie
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
- 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 App-owned Guards.