Technology Apr 18, 2026 · 13 min read

Build an Authorization Server with Bun, Hono, and OpenID Connect

Modern applications increasingly rely on OpenID Connect (OIDC) for secure, standards-based authentication. But most tutorials point you at hosted identity providers. What if you need to run your own authorization server maybe for a microservices platform, an internal tool, or simply to understand h...

DE
DEV Community
by ShyGyver
Build an Authorization Server with Bun, Hono, and OpenID Connect

Modern applications increasingly rely on OpenID Connect (OIDC) for secure, standards-based authentication. But most tutorials point you at hosted identity providers.
What if you need to run your own authorization server maybe for a microservices platform, an internal tool, or simply to understand how the flow works end to end?

In this article we'll build a fully working OIDC Authorization Code Flow server from scratch using:

By the end you'll have a server that issues signed JWTs, exposes a discovery endpoint, supports PKCE, and comes with an interactive Scalar UI for testing.

What we're building

The server will expose the following endpoints:

Endpoint Purpose
GET /.well-known/openid-configuration OIDC Discovery
GET /.well-known/jwks.json JSON Web Key Set
GET /authorize Login page
POST /authorize Login form submission
POST /token Token exchange
GET /userinfo Authenticated user claims
GET /protected-resource Example protected endpoint
GET /openapi.json OpenAPI spec
GET /scalar Interactive API explorer

Prerequisites

Make sure you have Bun installed.

Step 1: Create the project

Scaffold a new Hono project with Bun:

bun create hono@latest auth-server

When prompted, choose bun as the template. Then move into the project:

cd auth-server

Step 2: Install dependencies

bun add @saurbit/hono-oauth2 @saurbit/oauth2 @saurbit/oauth2-jwt hono-openapi @hono/standard-validator @scalar/hono-api-reference

Step 3: Write the server

Open src/index.ts and replace its contents step by step as shown below.

3.1 Imports

Start with all the imports needed for the server:

import { Hono } from "hono";
import { cors } from "hono/cors";
import { html } from "hono/html";
import { HTTPException } from "hono/http-exception";

import { describeRoute, openAPIRouteHandler } from "hono-openapi";
import { Scalar } from "@scalar/hono-api-reference";

import { HonoOIDCAuthorizationCodeFlowBuilder } from "@saurbit/hono-oauth2";

import {
  createInMemoryKeyStore,
  JoseJwksAuthority,
  JwksRotator,
} from "@saurbit/oauth2-jwt";

import {
  AccessDeniedError,
  StrategyInsufficientScopeError,
  StrategyInternalError,
  UnauthorizedClientError,
  UnsupportedGrantTypeError,
} from "@saurbit/oauth2";

3.2 Extend UserCredentials

@saurbit/oauth2 provides a UserCredentials interface you can augment via module augmentation. This lets the rest of the flow know what shape your user object has:

declare module "@saurbit/oauth2" {
  interface UserCredentials {
    id: string;
    email: string;
    fullName: string;
    username: string;
  }
}

3.3 Configure JWT key management

Set up an in-memory JWKS key store, a signing authority, and a rotator. The authority signs and verifies JWTs; the rotator generates new keys on a schedule and removes expired ones.

const ISSUER = "http://localhost:3000";
const DISCOVERY_ENDPOINT_PATH = "/.well-known/openid-configuration";

// In-memory key store (swap for a persistent store in production)
const jwksStore = createInMemoryKeyStore();

// Signs JWTs and exposes the public JWKS endpoint
const jwksAuthority = new JoseJwksAuthority(jwksStore, 8.64e6); // 100-day key lifetime

// Rotates keys every 91 days and cleans up expired ones
const jwksRotator = new JwksRotator({
  keyGenerator: jwksAuthority,
  rotationTimestampStore: jwksStore,
  rotationIntervalMs: 7.884e9, // 91 days
});

In production, replace createInMemoryKeyStore() with a persistent store backed by a database or secrets manager so keys survive restarts. We’ll explore persistent storage options in an upcoming article.

3.4 Define clients and users (in-memory)

For this example, a single client and a single user are hardcoded. In a real application, these would be read from a database.

const CLIENT = {
  id: "example-client",
  secret: "example-secret",
  grants: ["authorization_code"],
  redirectUris: [
    "http://localhost:3000/scalar",
  ],
  scopes: ["openid", "profile", "email", "content:read", "content:write"],
};

const USER = {
  id: "user123",
  fullName: "John Doe",
  email: "user@example.com",
  username: "user",
  password: "crossterm",
};

// Short-lived authorization code storage
const codeStorage: Record<
  string,
  {
    clientId: string;
    scope: string[];
    userId: string;
    expiresAt: number;
    codeChallenge?: string;
    nonce?: string;
  }
> = {};

3.5 Build the OIDC flow

HonoOIDCAuthorizationCodeFlowBuilder uses a fluent API. Each method registers a callback or configures an option; call .build() at the end to get the configured flow object.

Parse the authorization endpoint data

This callback extracts user credentials from the login form submission:

const flow = HonoOIDCAuthorizationCodeFlowBuilder.create({
  parseAuthorizationEndpointData: async (c) => {
    const formData = await c.req.formData();
    const username = formData.get("username");
    const password = formData.get("password");

    return {
      username: typeof username === "string" ? username : undefined,
      password: typeof password === "string" ? password : undefined,
    };
  },
})

Configure endpoints and scopes

  .setSecuritySchemeName("openidConnect")
  .setScopes({
    openid: "OpenID Connect scope",
    profile: "Access to your profile information",
    email: "Access to your email address",
    "content:read": "Access to read content",
    "content:write": "Access to write content",
  })
  .setDescription("Example OpenID Connect Authorization Code Flow")
  .setDiscoveryUrl(`${ISSUER}${DISCOVERY_ENDPOINT_PATH}`)
  .setJwksEndpoint("/.well-known/jwks.json")
  .setAuthorizationEndpoint("/authorize")
  .setTokenEndpoint("/token")
  .setUserInfoEndpoint("/userinfo")

Set client authentication methods

Support both private clients (client secret) and public clients (PKCE, no secret):

  .clientSecretPostAuthenticationMethod()
  .noneAuthenticationMethod()

Token lifetime and OIDC metadata

  .setAccessTokenLifetime(3600)
  .setOpenIdConfiguration({
    claims_supported: [
      "sub", "aud", "iss", "exp", "iat", "nbf",
      "name", "email", "username",
    ],
  })

Authenticate the client at the authorization endpoint

This runs before the login page is shown and validates that the client ID and redirect URI are known:

  .getClientForAuthentication((data) => {
    if (
      data.clientId === CLIENT.id &&
      CLIENT.redirectUris.includes(data.redirectUri)
    ) {
      return {
        id: CLIENT.id,
        grants: CLIENT.grants,
        redirectUris: CLIENT.redirectUris,
        scopes: CLIENT.scopes,
      };
    }
  })

Authenticate the user

Validate the submitted username and password. Return an { type: "authenticated", user } object on success, or undefined to reject:

  .getUserForAuthentication((_ctxt, parsedData) => {
    if (parsedData.username === USER.username && parsedData.password === USER.password) {
      return {
        type: "authenticated",
        user: {
          id: USER.id,
          fullName: USER.fullName,
          email: USER.email,
          username: USER.username,
        },
      };
    }
  })

Generate an authorization code

Create a random code, store it with a 60-second TTL, and return it:

  .generateAuthorizationCode((grantContext, user) => {
    if (!user.id) {
      return undefined;
    }
    const code = crypto.randomUUID();
    codeStorage[code] = {
      clientId: grantContext.client.id,
      scope: grantContext.scope,
      userId: `${user.id}`,
      expiresAt: Date.now() + 60000,
      codeChallenge: grantContext.codeChallenge,
      nonce: grantContext.nonce,
    };
    return { type: "code", code };
  })

Exchange the authorization code at the token endpoint

getClient is called when a client POSTs to /token. Validate the code, check expiry, and verify either the client secret or the PKCE code_verifier. Return an enriched client object whose metadata is forwarded to the token generator:

  .getClient(async (tokenRequest) => {
    if (
      tokenRequest.grantType === "authorization_code" &&
      tokenRequest.clientId === CLIENT.id &&
      tokenRequest.code
    ) {
      const codeData = codeStorage[tokenRequest.code];
      if (!codeData) return undefined;
      if (codeData.clientId !== tokenRequest.clientId) return undefined;
      if (codeData.expiresAt < Date.now()) {
        delete codeStorage[tokenRequest.code];
        return undefined;
      }

      if (tokenRequest.clientSecret) {
        // Private client - verify the secret
        if (tokenRequest.clientSecret !== CLIENT.secret) return undefined;
      } else if (tokenRequest.codeVerifier && codeData.codeChallenge) {
        // Public client - verify PKCE code_verifier against the stored code_challenge
        const data = new TextEncoder().encode(tokenRequest.codeVerifier);
        const hashBuffer = await crypto.subtle.digest("SHA-256", data);
        const hashArray = new Uint8Array(hashBuffer);
        const base64url = btoa(String.fromCharCode(...hashArray))
          .replace(/\+/g, "-")
          .replace(/\//g, "_")
          .replace(/=+$/, "");
        if (base64url !== codeData.codeChallenge) return undefined;
      } else {
        return undefined;
      }

      return {
        id: CLIENT.id,
        grants: CLIENT.grants,
        redirectUris: CLIENT.redirectUris,
        scopes: CLIENT.scopes,
        metadata: {
          accessScope: codeData.scope,
          userId: codeData.userId,
          username: USER.username,
          userEmail: USER.email,
          userFullName: USER.fullName,
          nonce: codeData.nonce,
        },
      };
    }
  })

The metadata field is a free-form record. Use it to pass context (user info, granted scopes) from this callback to generateAccessToken.

Generate access and ID tokens

Sign both tokens with the JWKS authority and return them:

  .generateAccessToken(async (grantContext) => {
    const accessScope = Array.isArray(grantContext.client.metadata?.accessScope)
      ? grantContext.client.metadata.accessScope
      : [];

    const registeredClaims = {
      exp: Math.floor(Date.now() / 1000) + grantContext.accessTokenLifetime,
      iat: Math.floor(Date.now() / 1000),
      nbf: Math.floor(Date.now() / 1000),
      iss: ISSUER,
      aud: grantContext.client.id,
      jti: crypto.randomUUID(),
      sub: `${grantContext.client.metadata?.userId}`,
    };

    const { token: accessToken } = await jwksAuthority.sign({
      scope: accessScope.join(" "),
      ...registeredClaims,
    });

    const { token: idToken } = await jwksAuthority.sign({
      username: `${grantContext.client.metadata?.username}`,
      name: accessScope.includes("profile")
        ? `${grantContext.client.metadata?.userFullName}`
        : undefined,
      email: accessScope.includes("email")
        ? `${grantContext.client.metadata?.userEmail}`
        : undefined,
      nonce: grantContext.client.metadata?.nonce
        ? `${grantContext.client.metadata?.nonce}`
        : undefined,
      ...registeredClaims,
    });

    return {
      accessToken,
      scope: accessScope,
      idToken,
    };
  })

Verify access tokens

Called by authorizeMiddleware on every protected route. Verify the JWT signature and return the resolved credentials:

  .tokenVerifier(async (_c, { token }) => {
    try {
      const payload = await jwksAuthority.verify(token);
      if (payload && payload.sub === USER.id && typeof payload.scope === "string") {
        return {
          isValid: true,
          credentials: {
            user: {
              id: USER.id,
              fullName: USER.fullName,
              email: USER.email,
              username: USER.username,
            },
            scope: payload.scope.split(" "),
          },
        };
      }
    } catch (error) {
      console.error("Token verification error:", {
        error: error instanceof Error
          ? { name: error.name, message: error.message }
          : error,
      });
    }
    return { isValid: false };
  })

Handle authorization failures

Customize the HTTP response when authorizeMiddleware rejects a request:

  .failedAuthorizationAction((_, error) => {
    console.error("Authorization failed:", { error: error.name, message: error.message });

    if (error instanceof StrategyInternalError) {
      throw new HTTPException(500, { message: "Internal server error" });
    }
    if (error instanceof StrategyInsufficientScopeError) {
      throw new HTTPException(403, { message: "Forbidden" });
    }
    throw new HTTPException(401, { message: "Unauthorized" });
  })
  .build();

3.6 Create the Hono app and register routes

Now wire everything up:

const app = new Hono();

app.use("/*", cors());

OIDC discovery endpoint returns the server's configuration so clients can discover endpoints automatically:

app.get(DISCOVERY_ENDPOINT_PATH, (c) => {
  const config = flow.getDiscoveryConfiguration(c.req.raw);
  return c.json(config);
});

JWKS endpoint returns the server's public keys so resource servers can verify tokens:

app.get(flow.getJwksEndpoint(), async (c) => {
  return c.json(await jwksAuthority.getJwksEndpointResponse());
});

Authorization endpoint (GET) validates the client, then renders the login form:

app.get(flow.getAuthorizationEndpoint(), async (c) => {
  const result = await flow.hono().initiateAuthorization(c);
  if (result.success) {
    return c.html(
      HtmlFormContent({ usernameField: "username", passwordField: "password" }),
    );
  }
  return c.json({ error: "invalid_request" }, 400);
});

Authorization endpoint (POST) processes the login submission:

app.post(flow.getAuthorizationEndpoint(), async (c) => {
  try {
    const result = await flow.hono().processAuthorization(c);

    if (result.type === "error") {
      const error = result.error;
      if (result.redirectable) {
        const qs = [
          `error=${encodeURIComponent(
            error instanceof AccessDeniedError ? error.errorCode : "invalid_request"
          )}`,
          `error_description=${encodeURIComponent(
            error instanceof AccessDeniedError ? error.message : "Invalid request"
          )}`,
          result.state ? `state=${encodeURIComponent(result.state)}` : null,
        ].filter(Boolean).join("&");
        return c.redirect(`${result.redirectUri}?${qs}`);
      }
      return c.html(
        HtmlFormContent({
          usernameField: "username",
          passwordField: "password",
          errorMessage: error.message,
        }),
        400,
      );
    }

    if (result.type === "code") {
      const { code, context: { state, redirectUri } } =
        result.authorizationCodeResponse;
      const searchParams = new URLSearchParams();
      searchParams.set("code", code);
      if (state) searchParams.set("state", state);
      return c.redirect(`${redirectUri}?${searchParams.toString()}`);
    }

    if (result.type === "unauthenticated") {
      return c.html(
        HtmlFormContent({
          usernameField: "username",
          passwordField: "password",
          errorMessage: result.message || "Authentication failed. Please try again.",
        }),
        400,
      );
    }
  } catch (error) {
    console.error("Unexpected error at authorization endpoint:", {
      error: error instanceof Error
        ? { name: error.name, message: error.message }
        : error,
    });
    return c.html(
      HtmlFormContent({
        usernameField: "username",
        passwordField: "password",
        errorMessage: "An unexpected error occurred. Please try again later.",
      }),
      500,
    );
  }
});

The processAuthorization result has four possible types:

  • code : authentication succeeded; redirect to the client with the authorization code.
  • error : something went wrong; check result.redirectable to decide whether to redirect or re-render the form.
  • continue : used for a consent/approval step (not implemented here).
  • unauthenticated : wrong credentials; re-render the login form with an error.

Token endpoint exchanges the authorization code for access and ID tokens:

app.post(flow.getTokenEndpoint(), async (c) => {
  const result = await flow.hono().token(c);
  if (result.success) {
    return c.json(result.tokenResponse);
  }
  const error = result.error;
  if (
    error instanceof UnsupportedGrantTypeError ||
    error instanceof UnauthorizedClientError
  ) {
    return c.json(
      { error: error.errorCode, errorDescription: error.message },
      400,
    );
  }
  return c.json({ error: "invalid_request" }, 400);
});

User info endpoint protected with the openid scope; returns standard claims:

app.get(
  flow.getUserInfoEndpoint() || "/userinfo",
  flow.hono().authorizeMiddleware(["openid"]),
  describeRoute({
    summary: "User Info",
    description: "Returns claims about the authenticated end-user.",
    security: [flow.toOpenAPIPathItem(["openid"])],
    responses: {
      200: {
        description: "User claims.",
        content: {
          "application/json": {
            example: {
              sub: "user123",
              username: "user",
              name: "John Doe",
              email: "user@example.com",
            },
          },
        },
      },
    },
  }),
  (c) => {
    const credentials = c.get("credentials");
    const user = credentials?.user;
    const scope = credentials?.scope || [];
    return c.json({
      sub: user?.id,
      username: user?.username,
      name: scope.includes("profile") ? user?.fullName : undefined,
      email: scope.includes("email") ? user?.email : undefined,
    });
  },
);

Protected resource requires the content:read scope:

app.get(
  "/protected-resource",
  flow.hono().authorizeMiddleware(["content:read"]),
  describeRoute({
    summary: "Protected Resource",
    description: "Requires a valid access token with the 'content:read' scope.",
    security: [flow.toOpenAPIPathItem(["content:read"])],
    responses: {
      200: {
        description: "Protected resource data.",
        content: {
          "application/json": {
            example: {
              message: "Hello, John Doe! You have accessed a protected resource.",
            },
          },
        },
      },
      401: { description: "Unauthorized." },
      403: { description: "Forbidden - insufficient scope." },
    },
  }),
  (c) => {
    const user = c.get("credentials")?.user;
    return c.json({
      message: `Hello, ${user?.fullName}! You have accessed a protected resource.`,
    });
  },
);

OpenAPI spec and Scalar UI:

app.get(
  "/openapi.json",
  openAPIRouteHandler(app, {
    documentation: {
      info: { title: "Auth Server API", version: "0.1.0" },
      components: {
        securitySchemes: { ...flow.toOpenAPISecurityScheme() },
      },
    },
  }),
);

app.get("/scalar", Scalar({ url: "/openapi.json" }));

3.7 Key rotation and login form

Rotate keys on startup, then schedule hourly checks:

await jwksRotator.checkAndRotateKeys();

setInterval(async () => {
  await jwksRotator.checkAndRotateKeys();
}, 3.6e6);

Finally, add the HtmlFormContent helper and the default export:

function HtmlFormContent(props: {
  errorMessage?: string;
  usernameField: string;
  passwordField: string;
}) {
  return html`
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Sign in</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
  <h1>Sign in</h1>
  ${props.errorMessage ? html`<p style="color:red">${props.errorMessage}</p>` : ""}
  <form method="POST">
    <label for="${props.usernameField}">${props.usernameField}</label>
    <input id="${props.usernameField}" name="${props.usernameField}" type="text" autocomplete="username" required />
    <label for="${props.passwordField}">${props.passwordField}</label>
    <input id="${props.passwordField}" name="${props.passwordField}" type="password" autocomplete="current-password" required />
    <button type="submit">Sign in</button>
  </form>
</body>
</html>`;
}

export default app;

Step 4: Run the server

bun dev

Open http://localhost:3000/scalar in your browser. You'll see the Scalar interactive API explorer pre-configured with the OpenID Connect security scheme.

Test credentials:

Field Value
Client ID example-client
Client Secret example-secret (or use PKCE with no secret)
Credentials Location body
Username user
Password crossterm

Click Authorize, complete the login form, and then try GET /protected-resource or GET /userinfo.

Protecting a separate resource server

In a microservices architecture, your resource servers are separate processes. They don't need the full OAuth2 library. They just need to verify the JWT each request carries.

Option A: Hono's built-in JWK middleware

Hono ships a hono/jwk middleware that fetches the public keys from the JWKS endpoint and verifies incoming bearer tokens automatically:

import { jwk } from "hono/jwk";

app.use(
  "/api/*",
  jwk({ 
    jwks_uri: "http://localhost:3000/.well-known/jwks.json",
    alg: ['RS256'],
  }),
);

Option B: jwks-rsa for Node.js or other runtimes

If you're running a Node.js service (Express, Fastify, etc.), use jwks-rsa to pull public keys from the JWKS endpoint and verify tokens with a JWT library like jsonwebtoken or jose.

import jwksClient from "jwks-rsa";

const client = jwksClient({
  jwksUri: "http://localhost:3000/.well-known/jwks.json",
});

Both approaches mean your resource servers remain stateless and never share a database with the authorization server.

What's next

  • Replace in-memory stores with a database (PostgreSQL, Redis, etc.)
  • Add a consent screen for multi-tenant applications
  • Support the refresh_token grant to issue long-lived tokens

The full runnable example is available at Github, while the conceptual guide is found at OIDC Authorization Code Flow with Hono.

DE
Source

This article was originally published by DEV Community and written by ShyGyver.

Read original article on DEV Community
Back to Discover

Reading List