DEV Community

Cover image for Build an Authorization Server with Bun, Hono, and OpenID Connect
ShyGyver
ShyGyver

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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

cd auth-server
Enter fullscreen mode Exit fullscreen mode

Step 2: Install dependencies

bun add @saurbit/hono-oauth2 @saurbit/oauth2 @saurbit/oauth2-jwt hono-openapi @hono/standard-validator @scalar/hono-api-reference
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
> = {};
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
})
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Set client authentication methods

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

  .clientSecretPostAuthenticationMethod()
  .noneAuthenticationMethod()
Enter fullscreen mode Exit fullscreen mode

Token lifetime and OIDC metadata

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

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,
      };
    }
  })
Enter fullscreen mode Exit fullscreen mode

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,
        },
      };
    }
  })
Enter fullscreen mode Exit fullscreen mode

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 };
  })
Enter fullscreen mode Exit fullscreen mode

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,
        },
      };
    }
  })
Enter fullscreen mode Exit fullscreen mode

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,
    };
  })
Enter fullscreen mode Exit fullscreen mode

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 };
  })
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

3.6 Create the Hono app and register routes

Now wire everything up:

const app = new Hono();

app.use("/*", cors());
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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,
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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,
    });
  },
);
Enter fullscreen mode Exit fullscreen mode

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.`,
    });
  },
);
Enter fullscreen mode Exit fullscreen mode

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" }));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Step 4: Run the server

bun dev
Enter fullscreen mode Exit fullscreen mode

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'],
  }),
);
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)