Access control middleware for Hono leveraging Cloudflare Workers' request.cf properties.
Add country blocking, ASN blocking, and maintenance mode to any route with a single line.
request.cfnative — Uses geo data Cloudflare Workers provides for free- Declarative API — Declare deny/allow lists, no hand-written conditionals
- Three middlewares —
countryBlock(),asnBlock(),maintenance() - RFC 9457 compliant —
application/problem+jsonerror responses - Customizable —
onDenied/onMaintenanceescape hatches for custom responses fallbackoption — Controls behavior whenrequest.cfis undefined (local dev)cfInfocontext variable — Normalized geo data accessible from handlers- Zero external dependencies — Only requires Hono as a peer dependency
npm install hono-cf-access- Hono
>= 4.0.0(peer dependency) - TypeScript
>= 5.0— the published.d.tsfiles are CI-tested against TS 5.0, 5.4, 5.7, and 5.9. Older TypeScript versions may work but are not verified. - Node.js
>= 22
import { Hono } from 'hono'
import { countryBlock } from 'hono-cf-access'
const app = new Hono()
// Deny access from specific countries
app.use('/api/*', countryBlock({
deny: ['CN', 'RU'],
}))
// Or allow only specific countries
app.use('/api/*', countryBlock({
allow: ['JP', 'US', 'GB'],
}))import { asnBlock } from 'hono-cf-access'
// Deny access from specific ASNs
app.use('/api/*', asnBlock({
deny: [4134, 4837],
}))
// Or allow only specific ASNs
app.use('/api/*', asnBlock({
allow: [13335, 209242],
}))import { maintenance } from 'hono-cf-access'
// Static toggle
app.use('/api/*', maintenance({
enabled: true,
}))
// Dynamic toggle via KV
app.use('/api/*', maintenance({
enabled: async (c) => {
const kv = c.env.MAINTENANCE_KV as KVNamespace
return (await kv.get('maintenance_mode')) === 'true'
},
}))
// With IP allowlist for admin access
app.use('/api/*', maintenance({
enabled: true,
allowedIps: [
'203.0.113.50',
'192.168.1.0/24',
],
retryAfter: 3600,
}))app.use('/api/*',
countryBlock({ deny: ['CN', 'RU'] }),
asnBlock({ deny: [4134] }),
maintenance({ enabled: async (c) => { /* ... */ } }),
)Each middleware operates independently. If one denies the request, subsequent ones are not executed.
All middlewares set a cfInfo context variable with normalized geo data:
app.get('/api/info', (c) => {
const info = c.get('cfInfo')
// info.country → 'JP'
// info.asn → 13335
// info.city → 'Tokyo'
// info.timezone → 'Asia/Tokyo'
return c.json(info)
})countryBlock({
deny: ['CN'],
onDenied: (c) => c.html('<h1>Access Denied</h1>', 403),
})
maintenance({
enabled: true,
onMaintenance: (c) => c.html('<h1>Under Maintenance</h1>', 503),
})The cf-connecting-ip header is injected by Cloudflare and is reliable only when the request actually reaches your Worker through Cloudflare's network. Outside that boundary — local development over plain HTTP, a non-Cloudflare reverse proxy, or test harnesses — the header is caller-controllable. The maintenance({ allowedIps }) option depends on this header to grant bypass access, so do not rely on it for security unless your deployment guarantees that all traffic is CF-terminated.
allowedIps accepts bare IPv4 and IPv6 addresses as well as CIDR blocks (e.g. 192.168.1.0/24, 2001:db8::/32). IPv6 zone IDs (fe80::1%eth0) and IPv4-mapped IPv6 addresses (::ffff:192.0.2.1) are normalised before matching, so you can use standard notation without worrying about representation differences.
A malformed CIDR entry (e.g. 192.168.1.0/33 or not:a:cidr/64) silently never matches any address. It does not throw — the entry is simply skipped, and no IP is granted a match by it. Audit your allowlist carefully: a typo in an admin CIDR will lock out that admin with no error.
For maintenance, fallback defaults to "deny" when the client IP cannot be resolved and allowedIps is set, preventing an accidental lockdown bypass. For countryBlock and asnBlock, fallback defaults to "allow" (permissive) when Cloudflare's cf metadata is absent — for example during local development. Set fallback: "deny" on those middlewares too if you need a stricter posture in environments where CF data may be unavailable.
This library performs access control based on Cloudflare-supplied geo and network data. It does not verify Cloudflare Access JWT tokens or service-token headers. Treating the mere presence of a header as proof of identity, without cryptographic signature verification, is unsafe. Use @hono/cloudflare-access alongside this library if you need Access JWT verification or service-token identity.
| Option | Type | Default | Description |
|---|---|---|---|
deny |
string[] |
— | Country codes to deny (ISO 3166-1 alpha-2) |
allow |
string[] |
— | Country codes to allow. All others denied |
fallback |
'allow' | 'deny' |
'allow' |
Behavior when request.cf is undefined |
onDenied |
(c: Context) => Response |
— | Custom response for denied requests |
Throws
BlockConfigErrorat initialization ifdenyandalloware both specified, neither is specified, or either is an empty array.
| Option | Type | Default | Description |
|---|---|---|---|
deny |
number[] |
— | ASN numbers to deny |
allow |
number[] |
— | ASN numbers to allow. All others denied |
fallback |
'allow' | 'deny' |
'allow' |
Behavior when request.cf is undefined |
onDenied |
(c: Context) => Response |
— | Custom response for denied requests |
Throws
BlockConfigErrorat initialization ifdenyandalloware both specified, neither is specified, or either is an empty array.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | (c: Context) => boolean | Promise<boolean> |
— | Whether maintenance mode is active |
allowedIps |
string[] |
— | IPs/CIDRs that bypass maintenance (IPv4 and IPv6) |
retryAfter |
number | string |
— | Retry-After header value |
fallback |
'allow' | 'deny' |
'deny' |
Fail-closed behavior when client IP cannot be resolved with allowedIps set. Opt into 'allow' for permissive fallback. |
onMaintenance |
(c: Context) => Response |
— | Custom maintenance response |
interface CfInfo {
country?: string
asn?: number
city?: string
region?: string
regionCode?: string
continent?: string
latitude?: string
longitude?: string
timezone?: string
postalCode?: string
}Returns the normalized CfInfo for a request, or undefined when request.cf is unavailable (e.g. local dev without the Cloudflare runtime). Use this when you need geo data without applying any block — for example, to read the country in a handler while leaving access control to a separate layer.
import { extractCfInfo } from 'hono-cf-access'
app.get('/api/region', (c) => {
const info = extractCfInfo(c)
return c.json({ region: info?.region ?? 'unknown' })
})When any block middleware (countryBlock, asnBlock, maintenance) runs, it already populates c.get('cfInfo') for downstream handlers; extractCfInfo is the manual escape hatch for routes that do not run a middleware.
Thrown synchronously by countryBlock() and asnBlock() when their deny / allow options are misconfigured. Subclass of Error with a middleware property identifying the offending caller, so a single catch can distinguish multiple call sites.
import { BlockConfigError, countryBlock } from 'hono-cf-access'
try {
countryBlock({ deny: [], allow: [] })
} catch (e) {
if (e instanceof BlockConfigError) {
// e.middleware === 'countryBlock'
// e.message === 'countryBlock: cannot specify both "deny" and "allow" — use one or the other'
}
}Throw conditions:
- Both
denyandalloware specified - Neither
denynorallowis specified - Either
denyorallowis an empty array
The error is thrown at the call to countryBlock() / asnBlock(), not at request time, so misconfiguration surfaces during Worker startup rather than as a runtime 500.
Default responses follow RFC 9457 Problem Details (Content-Type: application/problem+json):
| Scenario | Status | Type |
|---|---|---|
| Country denied | 403 | #country-denied |
| ASN denied | 403 | #asn-denied |
| Maintenance | 503 | #maintenance |
| CF data unavailable (strict) | 403 | #cf-unavailable |
Example response:
{
"type": "https://github.com/paveg/hono-cf-access#country-denied",
"title": "Forbidden",
"status": 403,
"detail": "Access from country 'CN' is not allowed",
"instance": "/api/data"
}Request was denied because the resolved country code is on the deny list (or not on the allow list).
Request was denied because the resolved ASN is on the deny list (or not on the allow list).
Request was rejected because maintenance mode is enabled. When allowedIps is set, only listed IPs bypass the lockdown.
Request was denied because request.cf geolocation data was unavailable and fallback is set to "deny".
@hono/cloudflare-access: Validates Cloudflare Access JWT tokens (authentication)hono-cf-access: Access control usingrequest.cfgeo data (authorization/filtering)
MIT