Known Limitations
Behavior to account for when choosing and deploying an integration. The API reference documents each option; this page collects cross-cutting limits and application-owned responsibilities.
Request logging
OAuth callbacks carry authorization codes and state in query parameters. Mount
requestLogger() from @udibo/oauth2/hono/log before authentication routes. It
replaces every query value with [redacted] in request and response lines; the
Juniper starter and examples use it by default. For other loggers or telemetry,
use redactedRequestTarget(url) before recording the URL.
The pathname and parameter names remain visible, so do not put secrets there. The middleware does not control reverse-proxy access logs, tracing exporters, or logs your own handlers emit. Configure those separately to avoid recording credentials, request bodies, or raw callback URLs.
Deliberate deviations (stricter than spec)
stateis required at the authorize endpoint. RFC 6749 §4.1.1 listsstateas RECOMMENDED; this server rejects authorize requests without it (invalid_request). This is a deliberate CSRF-hardening choice. PKCE also defaults to required (requirePKCE: true, enforced at both the authorize and token endpoints), matching OAuth 2.1. Keep this default for new integrations.Resource Owner Password grant ships but is discouraged. It exists for migration paths; OAuth 2.1 drops it. Nothing in the adapters or examples wires it by default.
The password grant does not enforce MFA, and cannot. A token request has no interactive step, so
PasswordGrantissues tokens for whatever userUserServiceInterface.getAuthenticatedreturns — including a user enrolled in a second factor. Registering the grant is therefore an opt-out of MFA unless yourgetAuthenticatedenforces the policy itself: returnundefined, or throw an OAuth2 error (InvalidGrantErrorsurfaces asinvalid_grant), for any user who owes a second factor.@udibo/oauth2/identity/mfaexposesMfaService/MfaStoreto ask that question.
Defaults that are looser than the specs
Confidential clients authenticate alongside PKCE by default as of 0.1.0.
requireClientAuthentication: false remains an explicit legacy opt-out and
should not be enabled for new deployments.
Both Basic and body client credentials are accepted, and the header wins silently.
extractClientCredentialsreads theAuthorization: Basicheader first and returns as soon as it parses;client_id/client_secretin the form body are consulted only when there is no usable header. RFC 6749 §2.3.1 says a client MUST NOT use more than one authentication method and that a server receiving both SHOULD refuse the request; this server picks one instead. The consequence worth knowing is a logging one: a request carryingBasic <credentials for A>andclient_id=Bin the body authenticates as A, while an access log or audit hook that readsclient_idoff the body records B. Log the authenticated client the grant hands you, never the request body'sclient_id.
Spec-level gaps
Introspection (RFC 7662) emits a subset of the optional fields:
active,client_id,token_type,scope,exp,iss,sub,username.subandusernameappear only when the token has a user; a machine token (client credentials) has neither, and itsclient_ididentifies the caller — so the presence ofsubis how a resource server tells a user token from a machine one. The typed response also declaresiat/nbf/aud/jti, but the server never populates them today. Practical consequence: a resource server that enforces audience restriction via introspectionaudcannot do so — issue JWT access tokens (createJwtAccessTokenGenerator) and validate them withJwksTokenReader, which enforcesaud, if you need in-token audience/issued-at claims.Introspection authorization is application policy. By default,
/introspectallows any admitted client to inspect any live token, preserving the separate resource-server use of RFC 7662. ConfigureAuthorizationServerOptions.canIntrospectTokento decide which resolved tokens each authenticated client may inspect. The callback receives the client, token and actual token kind, independent oftoken_type_hint. Returning false yields only{ active: false }, without claims enrichment; throwing fails the request closed. Revocation always checks ownership.The disclosure surface of one successful call is
active,client_id,scope,exp,iss, and — when the token has a resource owner —subandusername. That is enough to enumerate which user a captured token belongs to and what it may do. If your deployment has clients that should not learn that about each other, configure this policy or mount the endpoint behind a separate authorization boundary. A public client ID does not authenticate its holder, so matching a token's client ID alone does not protect public-client metadata. For confidential clients inspecting only their own tokens, require verified confidentiality as well as matching client IDs. A separately registered resource server needs an explicit policy for the tokens it may read; do not grant it every client's refresh-token metadata by accident.A live refresh token introspects as
active: true./introspectresolves both token kinds, so a refresh token reportsactive: truewithexptaken from its own expiry, and carries notoken_type(an access token carriestoken_type: "Bearer"). A caller that treats anyactive: trueresponse as "this is a valid access token" will accept a refresh token presented as a bearer — checktoken_typewhen the distinction matters. The shippedIntrospectionTokenReaderdoes exactly that: it accepts an active response only whentoken_typenames a bearer token, so an authorization server that omitstoken_typefrom active responses cannot be read by it.
Resource servers (@udibo/oauth2/server/resource)
Offline JWT validation does not consult token revocation state. A revoked token can remain valid until its signed expiry. Use an appropriate short lifetime or online validation when prompt revocation is required.
Clock skew is two options, and only one of them is on by default.
JwksTokenReaderOptions.clockSkewSeconds(default 30) governs the reader's ownexpandnbfclaim checks; the reader then handsResourceServera token whoseaccessTokenExpiresAtis the issuer's rawexp, andResourceServer.getTokenre-checks it withResourceServerOptions.clockSkewSeconds, which defaults to 0. Set both to tolerate drift end to end. The resource-server option is the only one that reaches anIntrospectionTokenReader, which has no notion of skew at all. Thenbfhalf is still reader-only — nothing re-checksnbf— so a slow-clock issuer is tolerated only by the reader.IntrospectionTokenReadercalls the authorization server on every request. It takesfetchTimeoutMs(default 5000, matchingJwksTokenReader), so an endpoint that accepts connections and then stops answering fails at the deadline rather than hanging — but there is still no response caching, so every authenticated request costs a round trip. Thefetchinjection seam remains the escape hatch for mTLS, retries, or a different bound; note that an injectedfetchwhich ignoresAbortSignalalso ignoresfetchTimeoutMs. Transport failures and timeouts both map totemporarily_unavailablerather thaninvalid_token, so they are correctly reported as "issuer is down", not "your token is bad".
Not implemented (adjacent specs)
Commonly requested capabilities that are simply not in scope yet — none are partially implemented or emulated:
Dynamic client registration (RFC 7591/7592)
Pushed Authorization Requests (PAR, RFC 9126)
JWT-secured authorization requests (JAR, RFC 9101) and JARM
Sender-constrained tokens (DPoP, RFC 9449; mTLS, RFC 8705)
Token exchange (RFC 8693)
OIDC logout-token emission (back-channel logout on the provider side), OIDC Session Management, and front-channel logout. RP-Initiated Logout is implemented on the provider side — configure the
endSessionoption and the server serves/end_sessionand advertisesend_session_endpoint; without it the endpoint 404s and stays unadvertised. The BFF also consumes both RP-initiated and back-channel logout as a relying party (rpInitiatedLogout,backchannelLogout)OIDC dynamic discovery of anything beyond the served metadata documents
OIDC connector validation
Generic oidcProvider and Google validate ID-token claims while trusting the
direct TLS token exchange for integrity. They do not verify ID-token signatures.
Apple verifies its signature separately. Do not pass out-of-band ID tokens to
connectors designed for a direct authorization-code exchange. Generic OIDC
requires HTTPS endpoints except loopback development URLs and refuses provider
fetch redirects. A trusted provider configuration is part of that boundary.
OIDC issuance scope
Signing is ES256 only (Web Crypto, zero dependencies). There is no RS256 option; verify your relying parties accept ES256 .
id_tokenclaims are released per scope through the app-provideduserClaimshook; there is noclaimsrequest-parameter support.acr/amrare not asserted.
Identity flows (@udibo/oauth2/identity)
Automatic password upgrades require atomic
IdentityUserStore.replaceCredential. Stores without it skip upgrades. A failed comparison re-verifies the password against the credential now stored: a login that raced with a reset to a different password is rejected, one that raced with another correct login is not.OTP
consumemust atomically return whether this caller consumed the record. Concurrent issuance is separate: invalidate/create are not one transaction, so serialize requests when only one code may remain outstanding.Uniform response bodies do not by themselves make request timing uniform. Account lookup, persistence, and email delivery can still expose differences. Throttle requests and avoid synchronously waiting for network mail delivery.
The built-in fixed-window rate limiter is deliberately feature-frozen; it is a floor, not a product. Two seams sit under it and they do different jobs:
RateLimiterLikereplaces the limiter — implementcheck/resetfor a sliding window, a token bucket, IP reputation, or a limiter you already run — whileRateLimitStoreonly relocates the counters the built-in fixed window keeps. A store cannot deliver a sliding window: the window arithmetic lives inRateLimiter.checkand the store is told nothing butwindowMs.AccountLockoutLikeis the matching seam for lockout policy. Both are structural, so a plain object with the right methods is assignable.MemoryRateLimitStoresweeps lapsed buckets and is hard-capped at 10,000, so attacker-chosen keys can no longer grow it without bound — but a bounded store must discard something, and at the cap it evicts. It evicts the coldest live buckets first (fewest hits, ties broken by soonest reset), never the oldest, so a unique-key flood discards the attacker's own single-hit junk rather than the counter that is throttling them. The residual is that an attacker willing to raise ~10,000 keys above a victim's hit count can still displace that victim's counter — roughly a 10x cost increase over a naive flood, not an impossibility. Bounding memory and resisting eviction are in genuine tension in a single process; this is a dev / single-process store, and production backs the limiter with Redis or a database where neither compromise is forced.rateLimitercovers every flow, keyed only by prefix, so a single threshold applies until you override the email-sending flows throughrateLimiters— per-flow limiters are configuration, not a default.IP-based throttling is a route-layer concern by design — the service has no request context.
The flows are single-app primitives: multi-tenancy, organizations, and SSO orchestration are intentionally out of scope for this library.
resetPasswordvoids outstanding passwordless credentials only as far as your stores let it. It now drops the subject's pending sign-in links (TokenFlowStore.deleteBySubject(TokenPurpose.SignIn, userId)) and the pending sign-in code (OtpStore.invalidate(email, purpose)) after the password changes. Both calls are best-effort:deleteBySubjectis optional onTokenFlowStore, so a store that does not implement it leaves outstanding links redeemable until they expire, and a store that throws is logged rather than failing the reset — the password has already changed by that point. The OTP half also depends on the reset token carryingdata.email, whichrequestPasswordResetsets; an app that mints reset tokens by callingTokenFlowService.createitself gets the link half only. Account-unlock and email-verification links are deliberately untouched — neither authenticates anyone.honoIdentityRoutes' CSRF guard trusts the browser, and only the browser. The factory mounts a same-origin guard on unsafe methods by default: a request whoseSec-Fetch-Siteis anything butsame-origin/noneis refused with403 { "error": "forbidden_origin" }, and for browsers that omit that header it falls back to comparing theOriginheader's host (not scheme — a TLS-terminating proxy leaves the request URL onhttp:). That is sound against a cross-site page, because page JavaScript cannot forge either header. It is not a synchronizer token: a caller that speaks HTTP directly sends neither header and is allowed through by design (that is not CSRF), and an intermediary that rewrites those headers defeats it. Safe methods are left to the router so preflights still work. Usecsrf: { allowedOrigins: [...] }for a legitimate cross-origin form, orcsrf: falsewhen an outer middleware already terminates CSRF.
Sessions (@udibo/oauth2/hono/bff)
EncryptedCookieSessionStorecannot genuinely revoke a session.destroy()is a no-op — statelessness is the whole point — so sign-out clears the browser's cookie and nothing else, and a captured copy stays valid until it either expires or ages out. Revoke the refresh token at the authorization server, or use a database-backedSessionStorewhere revocation is real and immediate. Two options shrink the window without makingdestroyreal:Secret rotation with a grace window.
secretaccepts an ordered list: the first entry seals every new cookie andreadtries each in turn, so rotatingSESSION_SECRETis a[new, old]deploy that keeps existing sessions readable rather than a fleet-wide forced sign-out. Drop the old secret once the grace window elapses. This mirrors the multi-key JWKS grace window for signing keys. A single secret still behaves as before.Bounded lifetime, on by default.
maxAgeMsstamps each cookie with a seal time and rejects any cookie older than the cap onread, bounding how long a captured cookie stays useful without any server-side state. It defaults to 14 days rather than being opt-in, and there is no unbounded setting — a bearer cookiedestroy()cannot revoke has to expire on its own. Becauseupdatere-seals with a fresh stamp, the cap is an inactivity window;HonoBff'ssessionMaxAgeMs(also 14 days) is the absolute one, and the constructor throws if the two are configured to disagree. Genuine revocation still requires a DB-backed store.
Back-channel logout is unavailable on any store that cannot enumerate sessions, and
HonoBffthrows at construction rather than degrading silently.attachToken()overwrites an inbound bearer;protect()lets it win. The two guards are presented as alternatives, and they differ here:attachToken()resolves the session andsetsAuthorization: Bearer <session token>unconditionally, replacing whatever the caller sent, whileprotect()checks for an inbound bearer first and validates that one directly (so one guard serves both the BFF's own frontend and machine-to-machine clients). Both behaviors are deliberate and pinned by tests. Neither is attacker-forceable from a browser: the package writes noAccess-Control-*header anywhere insrc/, so a cross-origin page cannot set anAuthorizationheader on a credentialed request, and the CSRF header check runs before either path. The practical rule is the one the difference implies — if a mount must serve machine-to-machine callers, useprotect(); if a mount must only ever act as the signed-in browser user,attachToken()is the one that guarantees it.SessionDataandListableSessionServiceare two layers, not a missing bridge.SessionData(in@udibo/oauth2/hono/bff) is the BFF's token custody record — the access token, refresh token, cached claims,sid.ListableSessionService(in@udibo/oauth2/identity) is the app login session seam a "where you're signed in" screen renders and revokes against. They describe different things, so there is deliberately no automatic mapping between them.The reason this reads as a gap is that
SessionDatacarries no id field — but it does not need one: everySessionStoremethod is keyed bycookieValue(read,update,destroyall take it, andcreatereturns it), so an app-owned store already holds the id for each record and can project its own rows intoSessionSummarydirectly. The correlating field is that internal record ID, which is safe to return asSessionSummary.id. Never exposecookieValueor its hash in a session summary: the cookie value authenticates requests. Keep a separate non-secret row identifier. When the app and the BFF should share one session record rather than keeping two, the documented bridge issessionMode: "shared", which attaches the tokens to the session your login already created.
Runtime
./cliis the only Deno-locked entrypoint. It usesDeno.serve,Deno.env,Deno.readTextFileandDeno.args, so it runs on Deno and nowhere else. Every other subpath is Web-standard — nonode:import and no runtime-specific global anywhere on the library path (theDeno.env.getyou see in connector JSDoc is example prose, not code the package runs). The CLI is a development tool (oidc keygen,idp dev); nothing on the library path imports it, so its floor never constrains the rest of the package. The full matrix is in the stability policy.

