---
title: "Protect an API Endpoint"
url: "https://effect-auth.itsbroly.com/recipes/protect-api-endpoint/"
description: "Authenticate one endpoint with a session cookie, JWT bearer token, or API key."
---



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

```text
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 [#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 [#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.

```ts
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 [#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 [#session-cookie]

```ts
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](/concepts/http-operations/), which already own cookie behavior. See [Sessions](/concepts/sessions/).

### JWT bearer [#jwt-bearer]

```ts
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](/recipes/issue-jwt-access-tokens/).

### API key [#api-key]

```ts
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](/recipes/add-api-key-authentication/).

## Test and secure the boundary [#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](/guides/app-owned-guards/).

