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:
- Bun : a fast all-in-one JavaScript runtime
- Hono : a lightweight, edge-ready web framework
-
@saurbit/hono-oauth2: a Hono integration for OAuth 2 / OIDC flows -
@saurbit/oauth2-jwt: JWT signing with automatic key rotation
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; checkresult.redirectableto 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_tokengrant 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.
This article was originally published by DEV Community and written by ShyGyver.
Read original article on DEV Community