The previous articles in this series built a working OIDC Authorization Code Flow server, fixed the hardcoded issuer, and discussed how to persist signing keys. Now we will cover another important step: the consent screen.
When a third-party application requests access to a user's account, that user should explicitly choose which permissions to grant and which to deny. Those permissions could be as broad as "full access to your account" or as specific as "read-only access to your calendar".
The OAuth 2 spec defines this step, and every public-facing authorization server needs it. Without it, any registered client silently gets every scope it asks for the moment the user authenticates.
This article adds a consent screen to the server built in the previous articles. After logging in, the user will see a page listing the requested scopes, with Allow and Deny buttons. A short-lived server-side session cookie bridges the login and consent steps so the server knows who is making the decision.
TL;DR
The full runnable example is available at Github (apps/oidc-consent-app).
What changes
The authorization flow now has two interactive steps instead of one:
- Login: user submits username and password.
- Consent: user sees the requested scopes and clicks Allow or Deny.
A session cookie ties these two steps together. After the user authenticates, the server stores a short-lived session and returns the consent page. On the next POST (the consent form submission), the server reads the session to identify the user and acts on their choice.
The generateAuthorizationCode callback becomes the control point. It now returns one of three outcomes:
| Return type | Meaning |
|---|---|
{ type: "continue" } |
Show the consent screen; don't issue a code yet |
{ type: "code", code } |
User approved; issue the authorization code |
{ type: "deny", message } |
User denied; return an access_denied error |
Step 1: Add imports and augment types
Add the cookie helpers from Hono and the AuthorizationCodeReqData type from the OAuth2 library:
import {
AccessDeniedError,
AuthorizationCodeReqData, // add this
StrategyInsufficientScopeError,
StrategyInternalError,
UnauthorizedClientError,
UnsupportedGrantTypeError,
getOriginFromRequest,
} from "@saurbit/oauth2";
import {
Env, // add this
Hono
} from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie"; // add this
The AuthorizationCodeReqData type is the base shape that the flow builder uses for data parsed at the authorization endpoint. We'll extend it in the next step.
The @saurbit/oauth2 module augmentation previously only extended UserCredentials. Add a second interface, AuthorizationCodeUser, to carry the consent decision alongside the user object through generateAuthorizationCode:
declare module "@saurbit/oauth2" {
interface UserCredentials {
id: string;
email: string;
fullName: string;
username: string;
}
// add this
interface AuthorizationCodeUser {
id: string;
email: string;
fullName: string;
username: string;
consentStatus?: "allow" | "deny" | undefined;
}
}
AuthorizationCodeUser is what the user argument in generateAuthorizationCode is typed as. Adding consentStatus here lets us read it inside that callback without a type cast.
Step 2: Define ParsedData
The flow builder's parseAuthorizationEndpointData callback returns data that is then forwarded to getUserForAuthentication and generateAuthorizationCode. Define a dedicated interface that extends the base type with the extra fields the consent flow needs:
interface ParsedData extends AuthorizationCodeReqData {
username?: string;
password?: string;
consent?: "allow" | "deny";
sessionCookie?: string;
}
consent captures which button the user clicked. sessionCookie carries the session ID so the server can look up the authenticated user on the consent POST without the user having to re-enter their password.
Step 3: Add session storage
In production, you would use a proper session store (e.g., Redis) with secure cookie flags and expiration handling. For this example, we'll keep it simple with an in-memory object. Note that this is not suitable for production use.
Add an in-memory map to hold active sessions between the login and consent steps:
// Simple in-memory session storage for demonstration (not for production use)
const sessionStorage: Record<
string,
{
userId: string;
expiresAt: number;
}
> = {};
Each entry is keyed by a random UUID that becomes the session cookie value. The expiresAt field is checked on every read to discard stale sessions.
Step 4: Update the flow builder
4.1 Pass the generic type parameter
HonoOIDCAuthorizationCodeFlowBuilder.create accepts optional generic type parameters. Pass ParsedData as the second argument so TypeScript infers the correct type inside the callbacks:
// Before
const flow = HonoOIDCAuthorizationCodeFlowBuilder.create({
// After
const flow = HonoOIDCAuthorizationCodeFlowBuilder.create<Env, ParsedData>({
Env is Hono's generic environment type and keeps the context type correct, imported in Step 1. ParsedData is the interface defined in Step 2.
4.2 Parse consent and session cookie
Update parseAuthorizationEndpointData to extract the consent choice and the session cookie in addition to the credentials:
parseAuthorizationEndpointData: async (c) => {
let formData: FormData | undefined = undefined;
if (c.req.method === "POST") {
try {
formData = await c.req.formData();
} catch (error) {
console.error("Error parsing form data:", {
error: error instanceof Error ? { name: error.name, message: error.message } : error,
});
}
}
const username = formData?.get("username");
const password = formData?.get("password");
const consent = formData?.get("consent");
const sessionCookie = getCookie(c, "session"); // add this
return {
username: typeof username === "string" ? username : undefined,
password: typeof password === "string" ? password : undefined,
consent: consent === "allow" || consent === "deny" ? consent : undefined, // add this
sessionCookie, // add this
};
},
This callback runs on both GET and POST requests to the authorization endpoint, so formData is only parsed when the method is POST. On a GET, formData is undefined and username, password, and consent will all be undefined. The sessionCookie read is unconditional because it is present in both requests when the session was already created.
4.3 Update getUserForAuthentication
The callback now has two paths: session-based identification and credential-based identification.
.getUserForAuthentication((_ctxt, parsedData) => {
// Path 1: user already authenticated, identify them from the session
if (parsedData.sessionCookie) {
const session = sessionStorage[parsedData.sessionCookie];
if (session) {
if (session.expiresAt <= Date.now()) {
// Session expired, clean up
delete sessionStorage[parsedData.sessionCookie];
} else if (session.expiresAt > Date.now() && session.userId === USER.id) {
return {
type: "authenticated",
user: {
id: USER.id,
fullName: USER.fullName,
email: USER.email,
username: USER.username,
consentStatus: parsedData.consent, // carry the consent decision forward
},
};
}
}
}
// Path 2: no valid session, validate credentials
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,
// no consentStatus yet, the user hasn't seen the consent screen
},
};
}
})
When a session cookie is present and valid, the callback returns authenticated with consentStatus set to whatever the user clicked or undefined if neither button was clicked yet (e.g., a GET request with a cookie). When credentials are submitted for the first time, consentStatus is absent because the consent screen hasn't been shown.
4.4 Update generateAuthorizationCode
This callback now drives the entire consent flow:
.generateAuthorizationCode((grantContext, user) => {
if (!user.id) {
return undefined;
}
if (user.consentStatus === "deny") {
return {
type: "deny",
message: "The user has denied consent for this application.",
};
}
if (user.consentStatus === "allow") {
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 };
}
// consentStatus is undefined, the user hasn't decided yet
return { type: "continue" };
})
The three branches map directly to the three states consentStatus can be in:
-
"deny": the user clicked Deny; return anaccess_deniederror to the client. -
"allow": the user clicked Allow; store the code and return it. -
undefined: the user just logged in or the GET request came in with a valid session; signal the flow to continue (show the consent page).
Step 5: Update the GET handler for the authorization endpoint
Previously, this handler only called initiateAuthorization. Now it checks whether the user already has a session and, if so, shows the consent page directly instead of the login form:
app.get(flow.getAuthorizationEndpoint(), async (c) => {
if (getCookie(c, "session")) {
const processedAuthorization = await flow.hono().processAuthorization(c);
if (processedAuthorization.type === "continue") {
// Valid session, no consent decision yet -> show the consent page
return c.html(
HtmlConsentContent({
app: processedAuthorization.continueResponse.context.client.id,
userFullName: processedAuthorization.continueResponse.user.fullName,
scope: processedAuthorization.continueResponse.scope,
})
);
}
if (processedAuthorization.type === "unauthenticated") {
// Session expired or invalid -> clear the cookie and show the login form
deleteCookie(c, "session", { path: "/" });
return c.html(HtmlFormContent({ usernameField: "username", passwordField: "password" }));
}
if (processedAuthorization.type === "error") {
return c.json({ error: "invalid_request" }, 400);
}
}
// No session -> validate the client and show the login form
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);
});
processAuthorization runs the full flow: it calls parseAuthorizationEndpointData, getUserForAuthentication, and generateAuthorizationCode. When a session cookie is present, getUserForAuthentication will identify the user from the session and generateAuthorizationCode will return continue (no consent decision yet), which is exactly the condition that triggers the consent page.
If the session has expired, getUserForAuthentication returns undefined (no valid session, no credentials submitted), which the flow translates to unauthenticated. The handler clears the stale cookie and falls back to the login form.
Step 6: Update the POST handler for the authorization endpoint
The POST handler needs to handle the full spectrum of outcomes: login success (show consent), consent allow (redirect with code), consent deny (redirect with error), login failure (re-render login form), and unexpected errors.
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 === "continue") {
// First POST (login form) -> create a session, set the cookie, show consent
const sessionId = crypto.randomUUID();
sessionStorage[sessionId] = {
userId: result.continueResponse.user.id,
expiresAt: Date.now() + 300000, // 5 minutes
};
setCookie(c, "session", sessionId, {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.html(
HtmlConsentContent({
app: result.continueResponse.context.client.id,
userFullName: result.continueResponse.user.fullName,
scope: result.continueResponse.scope,
})
);
}
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 "continue" branch is where the session is created. It only runs once, on the first POST (credential submission), because on subsequent POSTs getUserForAuthentication will already find the session and generateAuthorizationCode will return either "code" or "deny" depending on which button was clicked.
The "error" branch now also handles the "deny" case. When generateAuthorizationCode returns { type: "deny" }, the flow converts it to an AccessDeniedError and sets redirectable: true (assuming the client and redirect URI were already validated). The handler therefore redirects the browser to the client with error=access_denied in the query string.
Step 7: Add the consent page component
Add HtmlConsentContent alongside the existing HtmlFormContent function:
function HtmlConsentContent(props: { scope: string[]; app: string; userFullName: string }) {
return html` <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Consent</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<h1>Consent</h1>
<p>User: <b>${props.userFullName}</b></p>
<p><b>${props.app}</b> is requesting the following permissions:</p>
<form method="POST">
${props.scope
? html`<ul>
${props.scope.map((s) => html`<li>${s}</li>`)}
</ul>`
: ""}
<button type="submit" name="consent" value="allow">Allow</button>
<button type="submit" name="consent" value="deny">Deny</button>
</form>
</body>
</html>`;
}
Both buttons submit to the same POST endpoint. The difference is the value attribute on the name="consent" field, which parseAuthorizationEndpointData reads and stores as parsedData.consent.
How the full flow works
Here is the complete sequence from a browser's perspective:
- The client redirects the browser to
GET /authorize?client_id=...&scope=...&.... - No session cookie →
initiateAuthorizationvalidates the client and returns the login form. - The user submits credentials via
POST /authorize. -
getUserForAuthenticationvalidates them and returnsauthenticated(noconsentStatus). -
generateAuthorizationCodesees noconsentStatusand returns{ type: "continue" }. - The POST handler creates a session, sets the cookie, and returns the consent page.
- The user clicks Allow or Deny → another
POST /authorize, this time withconsent=allow(ordeny) in the body and the session cookie in the header. -
getUserForAuthenticationfinds the session and returnsauthenticatedwithconsentStatusset. -
generateAuthorizationCodeacts on the decision: issues a code or returns a deny error. - The POST handler redirects the browser back to the client with either
?code=...or?error=access_denied.
Improvements
In the scope of this article, we focused on the core consent flow. Here are some improvements that could be made:
- Secure forms against CSRF by including a hidden token in the form and validating it on submission.
- Remember consent decisions per user per client to skip the consent screen on repeat authorizations.
What's next
- Support the
refresh_tokengrant to issue long-lived sessions


Top comments (0)