# Protect a DaloyJS API with Okta

[Okta](https://developer.okta.com) provides workforce and customer identity with granular policies and the API Access Management add-on for issuing custom-scoped access tokens. This guide uses the official [`@okta/jwt-verifier`](https://github.com/okta/okta-jwt-verifier-js) package (4.x, stable) to validate tokens from a Custom Authorization Server.

**Diagram: Okta access-token verification**

Participants: Client app, Okta Custom Auth Server, DaloyJS API, Okta JWKS

1. **Client app -> Okta Custom Auth Server** (async) - User signs in. Okta issues a scoped access token (RS256) - iss = https://{domain}/oauth2/{asId}
2. **Client app -> DaloyJS API** (request) - Call API with Authorization: Bearer <access token>
3. **DaloyJS API -> Okta JWKS** (async) - OktaJwtVerifier fetches signing keys (cached 1h) - jwksRequestsPerMinute throttles fetches
4. **DaloyJS API -> DaloyJS API** (note) - verifyAccessToken checks issuer, audience, scp & assertClaims
5. **DaloyJS API -> Client app** (response) - Return protected data after requireAuth passes

Only tokens from a Custom Authorization Server are meant to be verified by your app. The Org Authorization Server issues opaque tokens that you introspect instead, never verify locally.

## 1. Configure an Okta Authorization Server

1. In the Okta admin console, go to **Security -> API -> Authorization Servers**. Use the built-in `default` server or create a new Custom Authorization Server (requires the API Access Management license).
2. Add **scopes** (e.g. `items:read`, `items:write`) and an **access policy** that allows your client app to request them.
3. Note the **Issuer URI** (e.g. `https://dev-12345.okta.com/oauth2/default`) and the client's **Client ID**.

## 2. Install

```ts
pnpm add @okta/jwt-verifier
```

## 3. Environment variables

```ts
# .env
OKTA_ISSUER=https://dev-12345.okta.com/oauth2/default
OKTA_CLIENT_ID=0oa1example2345
OKTA_AUDIENCE=api://default
OKTA_REQUIRED_SCOPE=items:read
```

## 4. Plugin

```ts
// src/plugins/okta.ts
import OktaJwtVerifier from "@okta/jwt-verifier";
import type { App } from "@daloyjs/core";

const verifier = new OktaJwtVerifier({
  issuer: process.env.OKTA_ISSUER!,
  clientId: process.env.OKTA_CLIENT_ID,
  // Defaults shown for transparency:
  cacheMaxAge: 60 * 60 * 1000, // 1 hour
  jwksRequestsPerMinute: 10,
});

export interface Principal {
  sub: string;
  scopes: string[];
  groups: string[];
  claims: Record<string, unknown>;
}

const expectedAudience = process.env.OKTA_AUDIENCE!;

export const oktaPlugin = {
  name: "okta",
  register(app: App) {
    app.decorate("verifier", {
      async verify(token: string): Promise<Principal> {
        const { claims } = await verifier.verifyAccessToken(token, expectedAudience);
        const scopes = Array.isArray(claims.scp)
          ? (claims.scp as string[])
          : typeof claims.scp === "string"
            ? (claims.scp as string).split(" ")
            : [];
        const groups = Array.isArray(claims.groups) ? (claims.groups as string[]) : [];
        return { sub: String(claims.sub), scopes, groups, claims };
      },
    });
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    verifier: { verify(token: string): Promise<Principal> };
    principal?: Principal;
  }
}
```

## 5. Guard a route

```ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { oktaPlugin } from "./plugins/okta.ts";
import { requireAuth } from "./plugins/auth.ts"; // from the Overview page

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
app.register(oktaPlugin);

app.get(
  "/items",
  {
    operationId: "listItems",
    hooks: requireAuth(process.env.OKTA_REQUIRED_SCOPE!),
    responses: {
      200: { description: "OK", body: z.object({ user: z.string() }) },
    },
  },
  ({ state }) => ({ status: 200, body: { user: state.principal!.sub } }),
);
```

## Custom claim assertions

The verifier can enforce extra claims at construction time. For example, to require that the token includes both `items:read` and `items:write` in the space-separated `scp` claim:

```ts
const verifier = new OktaJwtVerifier({
  issuer: process.env.OKTA_ISSUER!,
  clientId: process.env.OKTA_CLIENT_ID,
  assertClaims: {
    "scp.includes": ["items:read", "items:write"],
    "groups.includes": ["Engineering"],
  },
});
```

## Verifying ID tokens

Use `verifyIdToken(token, expectedClientId, expectedNonce?)` if your client also sends ID tokens (for example, to populate a user profile). Pass the nonce only when the original auth request included one.

## Custom JWKS URI

When the JWKS isn't under the issuer (e.g. you front Okta with a proxy), pass `jwksUri` explicitly:

```ts
const verifier = new OktaJwtVerifier({
  issuer: process.env.OKTA_ISSUER!,
  clientId: process.env.OKTA_CLIENT_ID,
  jwksUri: "https://dev-12345.okta.com/oauth2/v1/keys",
});
```

## Runtimes

`@okta/jwt-verifier` is a **Node-only** library (it imports Node modules transitively). It works on Node, Bun, and AWS Lambda. For [Cloudflare Workers](/docs/adapters), use `jose`'s `createRemoteJWKSet` + `jwtVerify` against the same issuer (the [Auth0](/docs/auth/auth0) page shows that exact pattern, only the issuer URL changes).

## Org server vs Custom Authorization Server

Only tokens from a **Custom Authorization Server** are meant to be verified by your app, those issuers look like `https://{domain}/oauth2/{asId}`. The Org Authorization Server (`https://{domain}`) issues opaque tokens that only Okta should consume. Validate those via the `/introspect` endpoint instead.

See also [Auth0](/docs/auth/auth0), [Clerk](/docs/auth/clerk), and the [auth integrations overview](/docs/auth).

---

Source: https://daloyjs.dev/docs/auth/okta