Skip to content

Search docs

Jump between documentation pages.

Browse docs

Protect a DaloyJS API with Okta

Okta provides workforce and customer identity with granular policies and the API Access Management add-on for issuing custom-scoped access tokens. This guide verifies tokens from a Custom Authorization Server with the built-in jwk(), which runs on every DaloyJS runtime. The official @okta/jwt-verifier is Node-only; the end of the page shows how to plug it in if you prefer it.

Okta access-token verification
Client appOkta Custom Auth ServerDaloyJS APIOkta JWKS
  1. 01asyncClient appOkta Custom Auth ServerUser signs in. Okta issues a scoped access token (RS256)iss = https://{domain}/oauth2/{asId}
  2. 02requestClient appDaloyJS APICall API with Authorization: Bearer <access token>
  3. 03asyncDaloyJS APIOkta JWKSjwk() fetches signing keys (cached)GET {issuer}/v1/keys
  4. 04noteDaloyJS APIDaloyJS APIjwk() checks issuer, audience, expiry; requireScopes() checks scp
  5. 05responseDaloyJS APIClient appReturn protected data after jwk() and requireScopes() pass
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

Nothing beyond @daloyjs/core.

3. Environment variables

bash
# .env
OKTA_ISSUER=https://dev-12345.okta.com/oauth2/default
OKTA_AUDIENCE=api://default

4. Verify tokens

ts
// src/auth.ts
import { jwk } from "@daloyjs/core";

const issuer = process.env.OKTA_ISSUER!;

export const okta = jwk({
  jwks: `${issuer}/v1/keys`,
  algorithms: ["RS256"],
  issuer,
  audience: process.env.OKTA_AUDIENCE!,
  maxLifetimeSeconds: 86_400,
});

declare module "@daloyjs/core" {
  interface AppState {
    // What jwk() stamps on every authenticated request.
    user?: { sub?: string; scopes: string[]; claims: Record<string, unknown> };
  }
}

Okta puts granted scopes in scp as an array; jwk() reads it into ctx.state.user.scopes, so requireScopes() works unchanged.

5. Guard a route

ts
import { z } from "zod";
import { App, rateLimit, requireScopes, secureHeaders } from "@daloyjs/core";
import { okta } from "./auth.ts";

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

app.get(
  "/items",
  {
    operationId: "listItems",
    hooks: requireScopes(["items:read"], { phase: "preBody" }),
    responses: {
      200: { description: "OK", body: z.object({ user: z.string() }) },
    },
  },
  ({ state }) => ({ status: 200, body: { user: String(state.user?.sub) } }),
);

Groups

Okta does not put groups in access tokens by default. In the authorization server, add a Claims entry named groups (value type Groups, with a filter such as Matches regex .*, included in the access token). Then:

ts
import { requireRoles } from "@daloyjs/core";

app.get(
  "/reports",
  {
    hooks: requireRoles(["Engineering"], { claim: "groups" }),
    responses: { 200: { description: "OK" } },
  },
  () => ({ status: 200, body: {} }),
);

Extra claim checks

For anything beyond issuer, audience, expiry, scopes, and groups, use the verify hook. Returning false answers 403:

ts
jwk({
  jwks: `${issuer}/v1/keys`,
  algorithms: ["RS256"],
  issuer,
  audience: process.env.OKTA_AUDIENCE!,
  verify: (claims) => claims.cid === process.env.OKTA_CLIENT_ID, // only this client app
});

If the JWKS sits behind a proxy rather than under the issuer, point jwks at that https:// URL instead. jwk() refuses redirects when fetching keys.

Using @okta/jwt-verifier instead

On Node-based runtimes you can keep Okta's own SDK and plug it into bearerAuth(). Return a user object so requireScopes() and requireRoles() keep working:

ts
import OktaJwtVerifier from "@okta/jwt-verifier";
import { bearerAuth } from "@daloyjs/core";

const verifier = new OktaJwtVerifier({ issuer: process.env.OKTA_ISSUER! });

app.use(
  bearerAuth({
    realm: "items-api",
    validate: async (token) => {
      try {
        const { claims } = await verifier.verifyAccessToken(token, process.env.OKTA_AUDIENCE!);
        const scopes = Array.isArray(claims.scp) ? (claims.scp as string[]) : [];
        return { sub: claims.sub, scopes, claims };
      } catch {
        return false; // 403
      }
    },
  }),
);

The SDK imports Node modules, so it does not run on Cloudflare Workers or Fastly. ID tokens are for your front end, not your API; if you must check one, use verifier.verifyIdToken(token, clientId, nonce).

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, from bearerAuth({ validate }); see opaque tokens.

See also Auth0, Clerk, and the auth integrations overview.

Edit this page on GitHub

Last updated