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
writes one line per request with every query value replaced by [redacted],
including the requests a handler dispatches to the app in process, which it
folds into the outer request's line. 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.
Request body limits
The package caps the bodies it reads before authenticating the caller. Each read
counts bytes as they arrive and stops at the cap, whatever the Content-Length
header claims, and a body with no Content-Length (chunked) is counted the same
way. The caps default to 64 KiB, far above the largest legitimate field, a JWT
of a few KiB, except the resource server's token search, which defaults to
16 KiB.
AuthorizationServercaps the token, revocation, introspection, device authorization and end-session endpoints withmaxBodyBytes. An oversized body gets413witherror: "invalid_request": RFC 6749 §5.2 names no error code for size, and413tells the client that retrying the same body cannot succeed.HonoBffcaps the back-channel logout receiver withbackchannelLogout.maxBodyBytes. An oversized body gets400witherror: "invalid_request", because OIDC Back-Channel Logout 1.0 §2.8 requires400for every failed logout request.verifyLogoutTokenis not called. When a middleware mounted before the receiver has already read the body (for example withc.req.parseBody()), the receiver reads Hono's parsed copy and the cap does not apply, so that middleware needs its own limit.ResourceServer(and soprotect(),authenticate()and anAuthorizationServer's userinfo endpoint) looks for an RFC 6750 §2.2access_tokenin a form-encodedPOSTbody when the request carries no bearerAuthorizationheader. It reads at mostmaxTokenBodyBytes(16 KiB, Node's default limit on a request's whole header block) from a copy of the body. A larger body is not searched: the request counts as carrying no token, soprotect()answers401, and the rest of the body is left unread for the route. Send the token in theAuthorizationheader from any client whose form bodies are larger.honoIdentityRoutescaps the sign-up, sign-in, password-reset and email-verification bodies withmaxBodyBytes. An oversized body gets413witherror: "invalid_request"before the identity service is called. As with the back-channel receiver, a middleware that already read the body leaves the routes Hono's parsed copy, uncapped.
The caps bound what the package reads, not what your own routes read. A route
behind protect() that parses its body still needs a request-size limit, in
your framework (for example Hono's bodyLimit middleware) or your reverse
proxy. Mount that limit ahead of protect() so an anonymous oversized body is
refused before any authentication work.
The udibo-oauth2 idp dev server caps its sign-in, consent and admin bodies at
64 KiB and answers a larger one with 413.
Response size limits
The package also caps every response its runtime code reads from a server it
calls; the @udibo/oauth2/testing suites, which run only in your tests, do not.
As with request bodies, each read counts bytes as they arrive and cancels the
response at the cap. A Content-Length over the cap is refused before any of
the body is read, and a chunked response with no Content-Length is cut off at
the cap.
JwksTokenReadercaps the discovery document and the JWKS withmaxResponseBytes, default 256 KiB. A larger response fails the key refresh withServerError, as a malformed one does. A body that stops arriving beforefetchTimeoutMsgivesTemporarilyUnavailableError.IntrospectionTokenReadercaps the introspection response withmaxResponseBytes, default 64 KiB, and fails the same way.breachedPasswordValidatorcaps the range response withmaxResponseBytes, default 1 MiB. A larger response counts as the range API being unavailable, sofailOpendecides whether the password is allowed.DirectClient,BffClientandcheckPermissionscap every authorization-server response at 64 KiB. The external connectors cap token, profile, UserInfo and Apple JWKS responses at 64 KiB, and an oversized error body is left out of the error message. These caps are fixed. An oversized OIDC UserInfo response is discarded like any other UserInfo failure, so the profile comes from the id_token claims alone.
A cap bounds how much a response can send, not how long it can take. The
external connectors also give each request they send a fixed 10-second
deadline: the token exchange, the profile and GitHub API reads, OIDC UserInfo,
and Apple's JWKS. The deadline covers the connection, the response headers and
the whole body, including an error body read for the message. A provider that
stops sending at any point fails the sign-in with ExternalAuthError
provider_error once it passes, and the connection is closed. An OIDC UserInfo
request that misses it is a UserInfo failure like any other, so the profile
comes from the id_token claims alone. The OIDC connector's discovery and token
requests go through DirectClient, which bounds each one at 10 seconds as well.
An injected fetch must pass the request's signal through, or none of these
deadlines reaches the transport.
BFF storage across instances
HonoBff requires sessionStore and authRequestStorage and has no default
for either; constructing it without one throws a TypeError naming the option.
The memory stores, MemorySessionStore and MemoryAuthRequestStorageFactory,
must be chosen explicitly and serve one process only: behind a load balancer a
session created on one instance is unknown on the others, so the user appears
signed out, and a sign-in whose callback reaches another instance fails. See
choosing the BFF's stores
for which store fits which deployment.
Rate limit and lockout storage across instances
RateLimiter and AccountLockout require a store and have no default;
constructing either without one throws a TypeError naming the option.
MemoryRateLimitStore and MemoryLockoutStore must be chosen explicitly and
serve one process only: behind a load balancer each instance keeps its own
counters, so a client gets the rate limit once per instance, an attacker gets
maxAttempts password guesses per instance, and a lock on one instance does not
stop sign-in on another. Pass a RateLimitStore and a LockoutStore over
Redis or a database every instance shares, with an atomic increment.
DirectClient storage outside a browser
In a browser document DirectClient keeps pending sign-ins in
SessionStorageAuthRequestStorage and the refresh token in memory. Outside a
browser it has no default for either. login and exchangeAuthorizationCode
throw a TypeError unless the client or the call has an authRequestStorage,
and handleAuthorizationCallback, startDeviceAuthorization and
pollDeviceToken throw one unless the client has a refreshTokenStorage. Each
check runs before any request and before a pending sign-in is claimed. A
client that only runs client credentials needs neither; it never stores a
refresh token, and drops one a non-conforming server returns. Pass
MemoryAuthRequestStorage and MemoryRefreshTokenStorage explicitly for a
single process, tests and local development.
Pending sign-in limits
Anyone can start a sign-in. HonoBff's /auth/login stores the pending
request (state, PKCE verifier, return path) before it knows who the caller is,
so the package bounds what each start can cost:
return_tois capped at 2048 UTF-16 code units.safeReturnTotreats a longer value as unsafe and returns the fallback, soHonoBffstores and redirects todefaultReturnToinstead. Change the cap withHonoBffOptions.maxReturnToLength(orsafeReturnTo'smaxLengthandloginContinuation'smaxReturnToLength);Infinityremoves it.MemoryAuthRequestStorageholds at most 1000 records. This is whatMemoryAuthRequestStorageFactorygives the BFF, and the client's fallback in a browser document withoutsessionStorage. It keeps pending sign-ins in one process, so a callback served by another instance fails with an unknownstate; use it only for a single process, tests and local development. A write beyond the cap evicts the oldest-written record, so memory stays bounded however many starts arrive. Raise or lower it withmaxEntries. The cap protects memory, not sign-in: a burst of anonymous starts evicts real users' pending sign-ins, and their callbacks then fail with an unknownstate.EncryptedCookieAuthRequestStoragekeeps nothing on the server. Each browser carries its own pending requests: at mostmaxPending(3), and fewer when keeping them all would push the sealed cookie past about 4 KB, which a browser drops silently. One browser's starts cannot evict another browser's. The size trim never drops the record being written, so a single record with a long non-ASCIIreturn_to(up to three UTF-8 bytes per code unit) can still exceed 4 KB; that browser's cookie is dropped and the sign-in fails. LowermaxReturnToLengthif your return paths are not ASCII. Use this store for any deployment with more than one instance, and for any deployment where sign-in can be reached anonymously from the internet.
A rate limit on the login route, in your framework or reverse proxy, still limits how often one client can start a sign-in. The caps only bound the memory each start can cost.
Authorization response issuer (RFC 9207)
The authorization server adds iss to every redirect from its authorization
endpoint and advertises authorization_response_iss_parameter_supported.
DirectClient.handleAuthorizationCallback, HonoBff's /auth/callback and
the external sign-in connectors check it before they redeem the code. The check
has these limits:
A request that resolves no
issuergets noiss. Such a server cannot serve metadata either, so configureissuerinresolve.A
DirectClientconfigured withendpointsdoes not checkiss. It has no issuer to compare against, so it accepts any response. A client that signs in with more than one authorization server needs anissuerfor each, which also makes the check refuse a response from the wrong one.A missing
issis accepted from a server that does not advertise it. Such a client is protected against a server that names the wrong issuer, but not against one that leavesissout.A login retargeted at another origin accepts two issuers, once the callback is told the origin. The BFF passes its
resolveOriginto the check. ADirectClientthat callslogin({ origin })passes the same origin tohandleAuthorizationCallback(url, { origin }); without it, only the discovered issuer is accepted. With the origin, the client accepts the discovered issuer and the same issuer moved to that origin, with its path unchanged. That covers servers with one issuer for every host and servers that take the issuer from the request's host. It trusts the origin, so pass only allowlisted origins.exchangeAuthorizationCodedoes not see the response. A server that completes the flow with it callsverifyAuthorizationResponseIssuerfirst.The connectors check
issonly on a callback that carries a code.ExternalAuthFlowsurfaces a providererrorcallback asprovider_errorwithout comparing itsiss, so an error from another provider is reported as this one's. No code is redeemed either way.oauth2Providerchecksissonly when configured withissuer. A plain OAuth2 provider publishes no discovery document, so the connector has nothing to compare against until you setissuer, and it requiresissonly when you also setauthorizationResponseIssParameterSupported.githubProvideranddiscordProviderdo not checkiss. Neither preset has an issuer configured to compare it with.The Apple connector does not require
iss. It has no discovery step, and Apple does not advertise the parameter, so it refuses anissother thanhttps://appleid.apple.combut accepts a callback without one.A custom
ExternalProviderchecksissitself. The flow refuses a callback with more than oneissand hands a single one tofetchProfileasinput.iss; comparing it with the provider's issuer is the connector's job.
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.
requireClientAuthentication: false remains an explicit legacy opt-out and
should not be enabled for new deployments.
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 itself; only anintrospectionClaimshook you configure can add them. Practical consequence: without such a hook, a resource server cannot enforce audience restriction via introspectionaud— 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.How completely a code replay revokes depends on the token store. RFC 6819 §4.4.1.1 has a replayed authorization code revoke the tokens issued from it. The grant asks
revokeCode(code, client, codeVerifierHash)to do that, passing the client that presented the code and, unless that client authenticated with its secret, the SHA-256 hex digest of thecode_verifierit presented. The store decides how far it goes:A
revokeCodethat ignoresclientrevokes on any client's replay, so a client that sees a spent code, such as from a log or a shared browser, can end the session it started. Honorclientand a different confidential client's replay revokes nothing.A public client, or any client when
requireClientAuthenticationisfalse, is named byclient_idalone. Its replay revokes only with the verifier that redeemed the code, and only when the store persistedToken.codeVerifierHashand compares it inrevokeCode. A store that ignorescodeVerifierHashlets anyone holding such a client's spent code end the session it started. A replay of such a client's code that presents no verifier revokes nothing, so a code it redeemed without PKCE (requirePKCE: false) has no replay detection. Tokens saved before the store recordedcodeVerifierHashcarry none, so a replay of their code by such a client revokes nothing either.onCodeReusenames the presenting client, which is the code's own client only whenrevokeCodehonorsclient.Without
exchangeCode, the grant claims the code and saves its token in two calls. A replay that lands between them finds the code claimed but no token yet, so it is refused and the token saved next stays live. WithexchangeCode, the claim and the save are one step and a replay always finds the token to revoke.
MemoryTokenServicehonorsclientandcodeVerifierHash, and implementsexchangeCodewhen constructed with theauthorizationCodeServiceit shares with the grant.
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. That signed expiry is the server's own when the token service forwards the grant's
expiresAtargument tocreateJwtAccessTokenGenerator: it clampsexpto the expiry the grant stores, refresh-family cap included, so the revocation window is no longer than the stored access-token lifetime. A customgenerateAccessTokenwrapper that drops that argument falls back to the generator'slifetimeSecondsalone.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 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".errorFormatcovers protected-resource refusals only. WitherrorFormat: "problem-details",protect(),handleAuthError,createErrorResponseand an authorization server's UserInfo endpoint answer RFC 9457 problem details asapplication/problem+json. RFC 6750 §3 defines the challenge but no error body. The problem body carrieserror, with the message indetailand not repeated aserror_description; theWWW-Authenticatechallenge carries botherroranderror_description. An authorization server's OAuth endpoints ignore the option and always answer in the RFC 6749 §5.2 format asapplication/json.
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.The application supplies authentication-event evidence (
auth_time,acr, andamr), and its code/token stores must retain the captured snapshot across refreshes. The ID token carries the recordedauth_timeandacr;amrand the access-token, UserInfo and introspection claims stay opt-in through the claims hooks. The package does not infer assurance or methods. Omit unknown evidence; legacy and userless tokens do not gain it automatically. See Preserve the authentication event for store, hook, and BFF integration.The server enforces
prompt=none,prompt=login,prompt=consentandmax_age, but it cannot show a login page:authenticateUserperforms the re-authentication and reports it withreauthenticated: true. Otherpromptvalues, such asselect_account, are refused unless listed inpromptValuesSupported, and then the application alone honors them. See Honorprompt,max_ageandacr_values.As a relying party,
HonoBffchecks the ID token'sauth_timeonly for amax_agepinned inextraParams, with 60 seconds of clock-skew allowance. It does not check a token response without an ID token, unlessforwardedParamsnamesscope(a browser could then dropopenidto skip the check, so a missing ID token is refused), and formax_age=0it requiresauth_timebut no particular age, since that value meansprompt=login. Both rules come from the instance that served/auth/login, recorded on the pending authorization request. A callback reads the record, whichever instance serves it. Its own options apply only to a record without them: one from an earlier release, or from anAuthRequestStoragethat does not return every field it was given.DirectClientdoes not checkauth_time; a caller that sendsmax_agethroughlogin({ extraParams })checks the claims itself.
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, and a successful claim retires the pair's older codes in the same operation. Concurrent issuance is separate: invalidate/create are not one transaction, so overlapping requests can leave several codes live until one is verified. Serialize requests, or keep one code per pair in the store, 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 cannot 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.MemoryLockoutStoreis bounded the same way: it forgets an account's failuresttlMs(24 hours) after the last one, never while the account's lock is active, and holds at mostmaxEntries(10,000) records. At the cap it drops expired records first, then evicts unlocked records before locked ones, the fewest failures first. Lockout keys are account ids, so displacing an active lock takes failures against more than 10,000 other accounts that each end locked. The TTL also means a slow guesser who waits a day between failures never reaches the threshold in this store; a store over your database keeps the count until a successful sign-in clears it.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 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. The same holds when a new sign-in in the same browser (an organization switch, a step-up) replaces the session: the browser receives the new cookie, but a copy of the earlier one still reads as signed in, and the BFF does not revoke its refresh token because an authorization server may extend that revocation to the new tokens (RFC 7009 §2.1). 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 seals and reads with that one key.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.
Refreshing across instances needs a store with a refresh lock.
HonoBffshares one token refresh between concurrent requests for a session on the same instance. Across instances it relies on the session store: a store that implementswithRefreshLock(RefreshLockStore) makes the instances refresh a session one at a time, and an instance that waited re-reads the session and uses the tokens its peer stored instead of spending the refresh token again.MemorySessionStoreimplements it for the BFFs that share one store object. A sharedSessionStorewithout it leaves a race when parallel requests for one session reach different instances near access-token expiry, as when a single-page app fires several calls after sitting idle. Against an authorization server that rotates refresh tokens, both instances present the same refresh token. The loser's exchange fails and it destroys the session, or it arrives a moment later with a rotated-out token, reuse detection revokes the whole rotation family, andonTokenReusereports a replay that was not an attack. Either way the user is signed out. ImplementwithRefreshLockon any store more than one instance shares, and check it withrunSessionStoreContractTests.A refresh lock that expires mid-refresh needs a lease to stay safe. A shared store's lock has to expire when its holder dies, so a holder that stalls past the expiry, through a paused process or starved timers, can resume while another instance holds the lock. When the store passes
refreshaRefreshLease,HonoBffwrites and destroys the session only through it, and the former holder can do neither. When the authorization server refuses the former holder's token because the new holder already spent it, the BFF re-reads the session: it uses the new holder's tokens if they are stored, and otherwise answers502and keeps the session and its cookie. A store whosewithRefreshLockpasses no lease gets no such fence: the former holder's write can overwrite the new holder's tokens, or its sign-out can end the session the new holder just refreshed.runSessionStoreContractTestsfails such a store unless its options declare that the lock never expires (refreshLockNeverExpires). Two schedules still cost the session, with or without the lease. A holder that stalls past its lease and then spends the refresh token before the new holder does cannot store what it received, and the new holder's exchange is refused, so the new holder ends the session. A holder that stops after its exchange and before its write leaves the stored refresh token rotated out, so the next refresh is refused, or trips reuse detection against an authorization server with no replay grace.With
EncryptedCookieSessionStore, a request carrying the cookie from before a refresh can replay the old refresh token. The session lives in the browser's cookie, so the server has nothing to lock or re-read. A refresh seals the rotated token into a new cookie that reaches the browser with that response, and a request the browser sent before then still carries the old one.HonoBff'srotatedCookieGrace, on by default, covers the instance that ran the refresh: for 10 seconds it answers the old cookie with the new session and its cookie, for up to 1000 replaced cookies. It forgets a session's chain when the session ends on that instance: sign-out, a sign-in replacing it, or a refresh the authorization server refuses. A request with the old cookie that reaches another instance, or arrives after the grace, presents the rotated-out refresh token: reuse detection revokes the family,onTokenReusefires, and both cookies stop working. An authorization server that does not rotate refresh tokens is unaffected. Where it rotates them, route each browser to one instance, or prefer a shared stateful store that implementswithRefreshLock.rotatedCookieGracetrades some of reuse detection's signal for fewer false sign-outs. Within the grace, a stolen copy of the pre-refresh cookie replayed to the instance that ran the refresh receives the new session instead of tripping reuse detection. A cookie store cannot revoke a copied cookie anyway, but the grace hides the replay fromonTokenReusefor that window. Shorten it withrotatedCookieGrace: { ttlMs }, or turn it off withrotatedCookieGrace: falseand accept that stragglers sign the user out.A sign-out during a refresh revokes the old refresh token, and only the authorization server can reach its successor. The sign-out request carries the session from before the refresh, so the BFF revokes the token the refresh is rotating out.
AuthorizationServeranswers that by revoking the token's rotation family, which ends the successor too, on any session store and any instance. That needs a token service whoserevokeRotatedstampsrotatedAtand that implementsgetRevokedRefreshTokenandrevokeFamily, asMemoryTokenServicedoes; see revocation. Another authorization server may revoke only the token it was given, and then the successor stays live until it expires. With a token service that does not implementrotate, a revocation can also reach the authorization server between the refresh grant's claim of the old token and its save of the successor.MemoryTokenServicekeeps a revoked family revoked, so that save stores the successor already revoked. A custom token store misses the successor in that gap unless it does the same.Without
rotate, a refresh that races another refresh of the same token can sign the person out. The refresh grant then claims the presented token and saves its successor in two storage calls. A second request that presents the token between them finds it revoked with no live successor, and reuse detection treats it as theft: the family is revoked andonTokenReusefires. A store that keeps a revoked family revoked, asMemoryTokenServicedoes, then saves the first request's successor already revoked, so the person is signed out; a store that does not leaves that successor live in a family the server revoked. A token store that implementsrotateclaims and saves in one step, and the second request gets a plaininvalid_grantinstead.MemoryTokenServiceimplements it; see atomic rotation.With
EncryptedCookieSessionStore, a refresh's own response can put a signed-out cookie back. Sign-out, a replacing sign-in, or a refused refresh ends the grace for the whole chain, including a refresh still in flight, so no later straggler gets that session back. The request that ran the refresh still answers with the new cookie it sealed. If that response reaches the browser after the sign-out's, the browser stores the cookie again. Where the authorization server revoked the successor's family, as above, the next refresh fails,onTokenReusereports it as a replay, and the browser is signed out, though an access token a resource server validates offline stays usable until it expires. Otherwise the browser is signed in again. A stateful store does not have this problem, because sign-out destroys the record the cookie names.A token refresh in flight on a replaced session can clear the new cookie. When a request on the earlier cookie starts a refresh just before a new sign-in in the same browser replaces that session, the refresh then fails as revoked and its response clears the session cookie, which by then names the new session. The browser is signed out and signs in again; the new session's record stays until it expires.
Back-channel logout is unavailable on any store that cannot enumerate sessions, and
HonoBffthrows at construction rather than degrading silently.Behind a TLS-terminating proxy, the request URL reports
http:. A Node server such as@hono/node-serversees the proxy's plain-HTTP hop. The BFF therefore takes the application's origin from configuration wherever that origin must match a registration. RP-Initiated Logout'spost_logout_redirect_uriusesresolveOrigin, else the origin of the client's configuredredirectUri. TheGET /auth/logoutsame-origin fallback accepts that origin as well as the request's own. Only withderiveRedirectUri, or with noredirectUriconfigured, does the BFF use the request's origin. That is how sign-in builds itsredirect_urithen, so a deployment like that behind a TLS proxy must setresolveOrigin.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
The
udibo-oauth2command is Node-only. It usesnode:http,node:fsandprocess, so it runs on Node 22 or later and nowhere else. It 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. Outside the command, the library path is Web-standard apart fromnode:crypto(timingSafeEqual) in/identityand/identity/migration, and one feature check: the defaultfetchusesDeno.createHttpClientwhen it exists, as the next item describes. The testing helpers (/testing,/testing/contract,/hono/bff/testing,/react/testing) register suites through Vitest. The full matrix is in the README; the support boundary is in the stability policy.npm 10, the npm Node 22 bundles, can crash installing next to an older Vitest. When a project pins
vitestto a release older than Vitest's newest one, npm before 11.6.0 fails any install in that project withCannot read properties of null (reading 'edgesOut'), whether or not it installs this package (npm/cli#9787). Vitest pins its optional peers (@vitest/browser-playwright,@vitest/coverage-v8and the rest) to its own version, and npm 10 crashes reconciling those pins with its newest release. npm 11.6.0 carries the fix (npm/cli#8448) and no npm 10 release does. Runnpm install -g npm@11on Node 22, or movevitestto its newest release.--legacy-peer-depsis not a fix: it avoids the crash by skipping peer installation for the whole project. This package needs Vitest only for its testing helpers.On Deno, a request that misses the package's own deadline moves later requests off the pooled connection. Deno's global
fetchsends every request to an origin over one pooled HTTP/2 connection, and aborting a request at its deadline resets only that request: if the connection itself has stopped answering — a proxy still routing it to a backend that restarted, say — it stays pooled, and every later request to that origin would time out on it as well. So when one of the requests the package bounds with a deadline of its own is sent with the defaultfetchto anhttpsorigin and misses that deadline before its response has fully arrived, every later default-fetchrequest from that process to that origin opens a connection of its own and closes it once the response headers arrive. Those requests are:JwksTokenReader's JWKS and discovery fetches andIntrospectionTokenReader(fetchTimeoutMs);checkPermissions;DirectClient's discovery, token, revocation and userinfo calls andBffClient's session probe;breachedPasswordValidator; and every request the external connectors send: the token exchange, the profile, UserInfo and GitHub API reads, Apple's JWKS, and the OIDC connector's discovery. Nothing else triggers the switch: not a request aborted for any other reason, not a deadline a caller sets on its own signal (DirectClient.fetch(url, { signal })), and not the calls that carry no package deadline —DirectClient.fetchandBffClient.fetch, and the BFF proxy. Those still take a connection of their own to an origin that has already switched, and their responses are the globalfetch's, unaltered. The switch trades connection reuse to that origin, for the life of the process, for not depending on a pooled connection that may be stalled. Plainhttp(which Deno sends over HTTP/1.1, where an abort already discards the connection) keeps the shared pool, and other runtimes use their globalfetchunchanged. An injectedfetchmanages its own connections and gets none of this; one that pins an HTTP/2 client of its own should replace that client after a timeout.

