@nanokajs/auth
Authentication utilities for Nanoka on Cloudflare Workers.
Provides credential validation, JWT issuance, and Hono middleware — built on crypto.subtle, zero runtime dependencies beyond Hono.
Install
pnpm add @nanokajs/auth
Peer dependencies: @nanokajs/core ^1.0.0 || ^2.0.0, hono ^4.0.0.
Usage
Basic (JSON body)
import { createAuth } from '@nanokajs/auth'
import { nanoka, d1Adapter } from '@nanokajs/core'
const app = nanoka(d1Adapter(env.DB))
const User = app.model('users', { /* fields */ })
const auth = createAuth({
model: User,
secret: env.AUTH_SECRET, // must be >= 32 chars
fields: { identifier: 'email', password: 'passwordHash' },
})
app.post('/login', auth.loginHandler()) // returns { accessToken, refreshToken }
app.post('/refresh', auth.refreshHandler()) // returns { accessToken }
app.use('/api/*', auth.middleware())
Cookie mode
Pass cookie: {} to enable HttpOnly cookie delivery. The cookie option is entirely optional — omitting it preserves the existing JSON body behavior.
const auth = createAuth({
model: User,
secret: env.AUTH_SECRET,
fields: { identifier: 'email', password: 'passwordHash' },
cookie: {
httpOnly: true, // default: true
sameSite: 'Lax', // default: 'Lax' ('Strict' | 'Lax' | 'None')
secure: true, // default: true
path: '/', // default: '/'
accessTokenName: 'access_token', // default: 'access_token'
refreshTokenName: 'refresh_token', // default: 'refresh_token'
},
})
When cookie is set:
loginHandler()sets twoSet-Cookieheaders (access_token,refresh_token) and returns{ ok: true }— tokens are not in the response body.refreshHandler()reads the refresh token from therefresh_tokencookie. If the cookie is absent andjwt.rotationisfalse, it falls back tobody.refreshTokento support non-browser clients. Whenjwt.rotationistrue, the body fallback is disabled — the cookie must be present or the request is rejected with 401. This prevents XSS-obtained refresh tokens from bypassing theSameSitecookie defence by sending them directly as JSON.- On successful refresh, the new access token is written back via
Set-Cookieand the body is{ ok: true }. Whenjwt.rotationistrue, the refresh token is also rotated and written back viaSet-Cookie: refresh_token=<new>. When rotation is disabled (the default), therefresh_tokencookie is not overwritten.
app.post('/login', auth.loginHandler())
app.post('/refresh', auth.refreshHandler())
Refresh token rotation
Refresh token rotation replaces the refresh token on every use, making stolen token reuse detectable on a best-effort basis.
Note: With the KV-backed
BlacklistStore, detection is eventually consistent — a small race window exists where two simultaneous refreshes from different regions may both succeed. For strong consistency, see Issue #115 (D1-backed store, planned).
Enabling rotation
import { createAuth, kvBlacklistStore } from '@nanokajs/auth'
const auth = createAuth({
model: User,
secret: env.AUTH_SECRET,
fields: { identifier: 'email', password: 'passwordHash' },
jwt: { rotation: true },
blacklist: kvBlacklistStore(env.REFRESH_BLACKLIST_KV),
})
jwt.rotationdefaults tofalse. When rotation is disabled, refresh tokens remain valid until theirexpand can be reused any number of times. If stolen token detection is required, enablerotation: truetogether with aBlacklistStore.blacklistis required whenrotation: true. Passingrotation: truewithoutblacklistthrows an error atcreateAuthcall time (fail-fast).- When rotation is enabled,
loginHandlerembeds ajticlaim in the refresh token.refreshHandlerverifies thejti, rejects it if it is already blacklisted, blacklists the oldjti, and issues a new refresh token with a freshjti.
Response shape
| Mode | refreshHandler response |
|---|---|
| rotation disabled (default) | { accessToken } |
| rotation enabled (JSON body) | { accessToken, refreshToken } |
| rotation enabled (cookie mode) | { ok: true } + Set-Cookie: access_token=<new>; Set-Cookie: refresh_token=<new> |
KV blacklist store
kvBlacklistStore(kv, opts?) stores blacklisted JTIs in a Workers KV namespace. TTL is derived from the token's exp claim, with a minimum of 60 seconds (KV lower bound).
kvBlacklistStore(env.REFRESH_BLACKLIST_KV, { prefix: 'my-app:bl:' })
| Option | Default |
|---|---|
prefix |
'nanoka-auth:bl:' |
KV keys are stored as SHA-256(jti) hex strings, not the raw JTI. This prevents JTI enumeration via KV list().
A D1-backed blacklist store is planned as a follow-up.
BlacklistStore interface
BlacklistStore is the interface that any custom blacklist implementation must satisfy.
export interface BlacklistStore {
add(jti: string, expiresAt: number): Promise<void>
has(jti: string): Promise<boolean>
// Optional: defense-in-depth pair-check with sub.
// If implemented, refreshHandler uses it instead of has/add.
addWithSubject?(jti: string, sub: string, expiresAt: number): Promise<void>
hasForSubject?(jti: string, sub: string): Promise<boolean>
}
| Method | Required | Description |
|---|---|---|
add(jti, expiresAt) |
Yes | Blacklist a JTI until expiresAt (Unix seconds) |
has(jti) |
Yes | Return true if the JTI is blacklisted |
addWithSubject(jti, sub, expiresAt) |
No (optional) | Blacklist a JTI paired with a sub. If implemented, refreshHandler calls this instead of add. |
hasForSubject(jti, sub) |
No (optional) | Return true only if the JTI is blacklisted and was associated with the given sub. If implemented, refreshHandler calls this instead of has. |
Implementing addWithSubject / hasForSubject provides defense-in-depth: even if an attacker obtains a valid JTI, they cannot reuse it under a different sub. Existing implementations that only implement add / has continue to work without any changes.
Note: It is recommended to implement
addWithSubjectandhasForSubjectas a pair. If only one of the two is implemented, the missing side falls back to the existingadd/haspath, and the sub-matching defense-in-depth check will not be applied.
Cross-sub overwrite trade-off:
addWithSubjectwrites a single entry keyed byjti(the value carries thesub). If an attacker who somehow obtains a validjticallsrefreshHandlerunder a differentsub, the existing blacklist entry for the originalsubis overwritten. This is a known design trade-off of single-key blacklists and is tracked for further hardening in Issue #115 (D1-backed store). In normal threat models the attacker cannot mint refresh tokens without the signing secret, so this remains a defense-in-depth concern rather than an exploitable bypass.
Rate limiting
loginHandler() does not include built-in rate limiting or brute-force protection. Protect the login endpoint at the infrastructure or middleware layer before deploying to production. Options include:
- Cloudflare WAF rate limiting rules (recommended for Workers deployments)
- A Hono middleware that tracks failed attempts per IP or identifier and returns
429 Too Many Requests
Without rate limiting, an attacker can enumerate passwords against any valid identifier at the speed the Worker allows.
CSRF considerations
middleware() only accepts Authorization: Bearer <token>. Cookie-based access tokens are not read by the middleware — they are intended for browser clients that forward the cookie through a backend-for-frontend (BFF) pattern, or for setups where httpOnly: false is explicitly configured so that JavaScript can read the token and attach it as a Bearer header.
When using SameSite cookies for browser flows, apply CSRF protection appropriate to your deployment:
| Scenario | Recommended approach |
|---|---|
| Same-site (same eTLD+1) | sameSite: 'Strict' — browser refuses cross-site cookie send |
| Cross-origin API | sameSite: 'Lax' + Double Submit Cookie (send a readable CSRF token in a separate non-HttpOnly cookie; validate it server-side on state-changing requests) |
SameSite: None requires secure: true and is only needed for explicitly cross-site embedded contexts. Avoid it unless you have a specific cross-origin embedding requirement.
API reference
createAuth(opts)
| Option | Type | Default | Description |
|---|---|---|---|
model |
NanokaModel<any> |
required | The user model |
secret |
string |
required | HS256 signing secret (min 32 chars) |
fields.identifier |
string |
required | Field name used as the login identifier (e.g. 'email') |
fields.password |
string |
required | Field name storing the password hash |
hasher |
Hasher |
pbkdf2Hasher |
Custom hasher interface (hash / verify) |
jwt.expiresIn |
number |
900 |
Access token TTL in seconds |
jwt.refreshExpiresIn |
number |
604800 |
Refresh token TTL in seconds |
jwt.rotation |
boolean |
false |
Enable refresh token rotation (requires blacklist) |
blacklist |
BlacklistStore |
undefined |
Blacklist store for rotation (required when rotation: true) |
cookie |
CookieOptions |
undefined |
Cookie delivery options (see below) |
CookieOptions
| Field | Type | Default |
|---|---|---|
httpOnly |
boolean |
true |
sameSite |
'Strict' | 'Lax' | 'None' |
'Lax' |
secure |
boolean |
true |
path |
string |
'/' |
accessTokenName |
string |
'access_token' |
refreshTokenName |
string |
'refresh_token' |
authMiddleware({ secret })
Standalone middleware (also available as auth.middleware()). Reads Authorization: Bearer <token>, verifies the HS256 signature, and calls c.set('user', payload).
Rejects requests that:
- have no
Authorizationheader - use a scheme other than
Bearer(case-insensitive) - carry an expired or tampered token
- carry a refresh token where an access token is expected
Limitations
Set-Cookieoverwrite is browser-dependent. When rotation is enabled in cookie mode,refreshHandlerwrites a newrefresh_tokenviaSet-Cookie. Whether the browser replaces the old cookie depends on cookie attribute matching (name, domain, path, secure). Ensure yourcookieoptions are consistent across all endpoints.- No built-in rate limiting.
loginHandlerdoes not throttle requests. Protect the endpoint at the infrastructure layer. - D1 blacklist store not yet available. The current blacklist implementation is KV-only. A D1-backed implementation is planned as a follow-up issue.
Security notes
- Do not expose
HTTPException.causein responses or logs. Starting from v1.6.0,@nanokajs/authno longer setscauseon theHTTPExceptionit throws, so internal verify errors no longer surface through the default HonoonErrorhandler. However, if you implement a customonErroror exception filter, be careful not to serializecauseor stack traces into responses — the same principle applies to any exceptions you throw yourself. - KV keys are hashed.
kvBlacklistStorestoresSHA-256(jti)as the KV key. Raw JTI values are never written to KV keys, preventing JTI enumeration viakv.list().