ens-avatar
Avatar resolver library for Node.js, browsers, and edge runtimes (Cloudflare Workers etc.).
Important Notes
- Bring your own chain client.
AvatarResolvertakes aChainClientinstead of a raw provider, so the core ships with no ethers in the bundle. Wrap your SDK with a one-line adapter:- ethers:
import { fromEthers } from '@ensdomains/ens-avatar/ethers'→new AvatarResolver(fromEthers(provider)) - viem:
import { fromViem } from '@ensdomains/ens-avatar/viem'→new AvatarResolver(fromViem(client)) ethersandviemare optional peer dependencies — install only the one you use.
- ethers:
- Version 1.0.4+ uses the native Fetch API for maximum compatibility across platforms including Cloudflare Workers and other edge runtimes.
- Name resolution uses the ENS Universal Resolver, so offchain (gateway/L2) and ENSIP-10 wildcard names resolve out of the box. The ethers adapter fetches the owner address and the avatar/header record in a single CCIP-read-aware call (plus one more only to classify an ambiguous failure); the viem adapter uses viem's
getEnsTextandgetEnsAddress(two calls). Override the contract via the adapter'suniversalResolverAddressoption for non-default networks. - NFT avatars must be on the client's chain. Both adapters report their chain id, and an
eip155:<chainId>/…avatar on another chain throwsChainMismatchinstead of being read from the wrong chain. A customChainClientopts in by implementinggetChainId().
Platform Support
This library works seamlessly across:
- ✅ Node.js 22+ (uses native fetch or http adapter)
- ✅ Browsers (all modern browsers)
- ✅ Cloudflare Workers
- ✅ Other edge runtimes that support standard Fetch API
Getting started
Prerequisites
- Have an ethers v6 provider or a viem public client ready.
And good to go! SVG sanitization is built in and runs identically in browsers, Node.js, and edge runtimes (Cloudflare Workers) — no DOM polyfill (jsdom) required.
Installation
# npm
npm i @ensdomains/ens-avatar
# pnpm
pnpm add @ensdomains/ens-avatar
# yarn
yarn add @ensdomains/ens-avatar
Usage
With ethers:
import { JsonRpcProvider } from 'ethers';
import { AvatarResolver, utils as avtUtils } from '@ensdomains/ens-avatar';
import { fromEthers } from '@ensdomains/ens-avatar/ethers';
const provider = new JsonRpcProvider(/* ... */);
const resolver = new AvatarResolver(fromEthers(provider));
async function getAvatar() {
const avatarURI = await resolver.getAvatar('tanrikulu.eth');
// avatarURI = https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR
}
async function getHeader() {
const headerURI = await resolver.getHeader('tanrikulu.eth');
// headerURI = https://ipfs.io/ipfs/QmRFnn6c9rj6NuHenFVyKXb6tuKxynAvGiw7yszQJ2EsjN
}
async function getAvatarMetadata() {
const avatarMetadata = await resolver.getMetadata('tanrikulu.eth');
// avatarMetadata = { image: ... , uri: ... , name: ... , description: ... }
const headerMetadata = await resolver.getMetadata('tanrikulu.eth', 'header');
// headerMetadata = { image: ... , uri: ... , name: ... , description: ... }
const avatarURI = avtUtils.getImageURI({ metadata: avatarMetadata });
// avatarURI = https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR
}
With viem:
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import { AvatarResolver } from '@ensdomains/ens-avatar';
import { fromViem } from '@ensdomains/ens-avatar/viem';
const client = createPublicClient({ chain: mainnet, transport: http() });
const resolver = new AvatarResolver(fromViem(client));
const avatarURI = await resolver.getAvatar('tanrikulu.eth');
Supported avatar specs
NFTs
- ERC721
- ERC1155
URIs
- HTTP
- Base64
- IPFS
Options
Cache (Default: Disabled)
const avt = new AvatarResolver(provider, { cache: 300 }); // 5 min response cache in memory
Custom IPFS Gateway (Default: https://ipfs.io)
const avt = new AvatarResolver(provider, { ipfs: 'https://dweb.link' });
Custom Arweave Gateway (Default: https://arweave.net)
const avt = new AvatarResolver(provider, { arweave: 'https://arweave.net' });
Marketplace Api Keys (Default: {})
const avt = new AvatarResolver(provider, {
apiKey: {
opensea: 'YOUR_API_KEY',
},
});
URL DenyList (Default: [])
Hostnames to refuse (subdomains included). Entries are hostnames, not URLs; matching ignores case and trailing dots.
const avt = new AvatarResolver(client, {
urlDenyList: ['maliciouswebsite.com'],
});
Limits (Defaults: 30000 ms, 1 MiB, 5 redirects, 256 KiB SVG)
timeout— deadline for each HTTP request as a whole: redirects, headers and body.maxContentLength— maximum size of a fetched response body (e.g. NFT metadata JSON); larger responses are rejected.maxRedirects— redirects followed per request. Each hop is a subrequest; with the default, one resolution makes at most ~30 HTTP requests (under Cloudflare Workers Free's 50).maxSvgLength— maximum length of an inline /data:SVG avatar; larger ones resolve tonull.utils.sanitizeSVG(svg, { maxLength })takes the same limit.
Values must be positive integers (maxRedirects may be 0); NaN, Infinity and the like throw instead of silently disabling a limit.
const avt = new AvatarResolver(client, {
timeout: 8000,
maxContentLength: 512 * 1024,
maxRedirects: 3,
maxSvgLength: 128 * 1024,
});
Custom Agents (Node.js only)
You can provide custom HTTP/HTTPS agents for advanced use cases:
import http from 'http';
import https from 'https';
const avt = new AvatarResolver(provider, {
agents: {
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true }),
},
});
⚠️ SECURITY WARNING: When you provide custom agents, ens-avatar will use them as-is without applying SSRF protection. You are responsible for ensuring your custom agents have appropriate security measures to prevent Server-Side Request Forgery attacks.
If you need SSRF protection with custom agents, wrap them with ssrf-req-filter:
import http from 'http';
import https from 'https';
const { requestFilterHandler } = require('ssrf-req-filter');
const avt = new AvatarResolver(provider, {
agents: {
httpAgent: requestFilterHandler(new http.Agent({ keepAlive: true })),
httpsAgent: requestFilterHandler(new https.Agent({ keepAlive: true })),
},
});
Allow Private IPs (Default: false) - Development Only
For local development when you need to access localhost or private network services:
const avt = new AvatarResolver(provider, {
allowPrivateIPs: true, // Allows localhost, 127.0.0.1, 10.x.x.x, 192.168.x.x, etc.
});
⚠️ WARNING: This disables SSRF protection. Only use for local development (e.g., local IPFS nodes, test servers). NEVER enable this in production.
Common local development scenarios:
- Local IPFS node:
http://127.0.0.1:5001 - Local Ethereum node:
http://localhost:8545 - Docker containers on private networks
Note: This flag is ignored if you provide custom agents (you control security in that case).
Cloudflare Workers
- viem: create the client with a
chain. Without one the adapter has to ask the RPC foreth_chainId; it does so without viem's request dedupe, because Workers hang when a promise started in one request is awaited in another. - viem transport: don't enable request batching (
http(url, { batch: true })) orbatch: { multicall: true }on a client shared across requests: both hold calls in a queue whose promises can end up awaited by a different request, which hangs. - Requires viem 2.35 or later (Universal Resolver v3 error handling).
- ethers: 6.13 or later is required (earlier 6.x releases never time out a hung request, even with
FetchRequest.timeoutset). Usenew JsonRpcProvider(url, network, { staticNetwork: true, batchMaxCount: 1 }), so the provider neither re-detects the network nor holds calls in a batch shared across requests.
Security
getMetadatareturns metadata as published. Fields likeimage,image_urlandimage_dataare raw, attacker-controlled values; render them only throughgetAvatar/getHeader/utils.getImageURI, which validate and sanitize them.
XSS Protection
All user-generated SVG content is automatically sanitized to prevent XSS attacks:
- A single, parser-based sanitizer (sanitize-html + postcss) runs identically in browsers, Node.js, and edge runtimes (Cloudflare Workers) — no DOM or jsdom required.
- Tags and attributes are restricted to a strict SVG allowlist (no
script,foreignObject, event handlers, or externalhref/use/imagetargets). - CSS in
styleattributes and<style>blocks is filtered against a property allowlist, and every CSS function must be on a function allowlist (colors,calc/min/max/clamp, transforms, filter functions, basic shapes, timing functions; notvar,env,attr,src,image-set, …). Presentation attributes (fill,stroke,filter,mask,clip-path,marker-*, …) are checked the same way. url()is internal-only everywhere — attributes,styleattributes and<style>blocks: it may only reference#idwithin the document, so gradients, clips, masks, filters and markers keep working while external loading (tracking, exfiltration) is blocked.href(andxlink:href, which is renamed tohref) is likewise limited to#id, plusdata:image/*on<image>/<feImage>.- Every
<svg>element is pinned to the SVG namespace (xmlnsis added when missing), so a document can't be switched to XHTML. - Common editor output survives: Figma filter chains (
fe*primitives incl.feDropShadow), Illustrator<style>blocks wrapped in a single CDATA section, gradient inheritance viahref, and presentation attributes on any element.
Raster avatars (png, jpeg, gif, webp, …), whether served as data: URIs or remote URLs, are passed through after validation — they are inert as <img> sources and are not (and need not be) rewritten.
Inline vs. remote SVGs
How an SVG avatar is handled depends on where it comes from:
- Inline SVGs — on-chain
<svg>…</svg>records anddata:image/svg+xmlURIs — are returned already sanitized.getAvatar/utils.getImageURIhand back adata:image/svg+xml;base64,…URI with scripts, event handlers, and external references stripped. - Remote SVGs — an avatar whose record is an
http(s)URL that points at an SVG — are returned as the raw URL, unsanitized. ens-avatar is a resolver: for remote images it returns where the image lives, not its bytes, so the safe-rendering strategy is yours to pick. (The content-type check performed during resolution is not a safety guarantee — a server can advertiseimage/svg+xmland still return a hostile body.)
When you render a remote SVG, either load it in a context that already sandboxes it — an <img src> tag, a CSS background-image, or an <image href> inside another SVG, none of which execute scripts or load external sub-resources — or, if you fetch the bytes to serve, store or inline them, sanitize them first with the same engine the library uses for inline SVGs:
import { utils as avtUtils } from '@ensdomains/ens-avatar';
// `avatarUrl` came back from resolver.getAvatar(...) and points at an SVG
const svg = await fetch(avatarUrl).then(res => res.text());
const safeSvg = avtUtils.sanitizeSVGDocument(svg, { maxLength: 1024 * 1024 });
// safeSvg: a standalone '<svg …>…</svg>' document, or null if nothing usable remains
sanitizeSVGDocument(svg, { maxLength }): string | null returns only the root <svg> element, so the result is a valid standalone document (text outside the root, e.g. a DOCTYPE's internal subset, is dropped), and keeps whitespace as is. maxLength defaults to 256 KiB. sanitizeSVG(svg, { maxLength }) is the lower-level function: it returns the sanitized markup as a string ('' when rejected), including any text outside the root. Both reject malformed input up front: a < that doesn't start a tag, comment, CDATA section, processing instruction or declaration isn't valid XML (no SVG renderer would display it) and is the most expensive input to sanitize.
For an SSRF-safe fetch (private-address blocking, redirect re-validation, size caps — see below), use the library's own fetcher instead of the global fetch: const { get } = avtUtils.createFetcher();.
SSRF Protection
By default, ens-avatar includes built-in protection against Server-Side Request Forgery (SSRF) attacks in Node.js environments. This prevents malicious actors from using avatar URLs to probe internal networks.
Default behavior (recommended for production):
- ✅ Only
http:andhttps:URLs are fetched - ✅ Only globally routable unicast addresses are allowed: loopback, private, link-local, CGNAT, multicast, reserved, documentation and other special-purpose ranges are blocked, in every textual encoding (IPv4-mapped/NAT64 IPv6, trailing dots, …)
- ✅ Every redirect hop is re-validated, and request headers other than
Accept/Range(API keys) are dropped once a redirect leaves the original origin - ✅ Each request has one deadline covering the body, response bodies are size-capped, and redirects are limited (see Limits)
Custom agents: If you provide your own HTTP/HTTPS agents, ens-avatar will use them as-is without applying SSRF protection. You are responsible for securing your custom agents.
Local development: Set allowPrivateIPs: true to disable SSRF protection when you need to access local services (e.g., local IPFS nodes). Never use this in production.
See the Custom Agents section for more details.
Guarding CCIP-read
ens-avatar never makes CCIP-read (EIP-3668) requests itself: offchain lookups are performed by your chain client (ethers' provider or viem's ccipRead), with that client's HTTP stack and limits. A name's gateway is attacker-controlled input, so on a server:
Guard it with your client's hook — viem: ccipRead: { request } on createPublicClient; ethers: FetchRequest.registerGetUrl — and in it:
- Check the URL you actually request, i.e. after substituting
{sender}and{data}into the gateway template. Checking the template is not enough:https://{data}/with data0x7f000001targets127.0.0.1. UseavtUtils.validateUrl(url). - Don't let
fetchfollow redirects. Useredirect: 'manual'and runvalidateUrlon everyLocationhop, with a hop limit. - Bound each request (timeout, response size) and the lookup as a whole: the number of gateway URLs tried and nested lookups (ethers stops at 10,
MAX_CCIP_REDIRECTS; viem has no limit of its own). - On Node, also check resolved addresses.
validateUrlonly sees the URL: a gateway hostname whose DNS points at a private address passes it. Make the gateway requests through an agent that checks the IP it connects to (e.g. an undiciAgentwith a validatinglookup, orssrf-req-filter).
Serving avatars
If you proxy or re-serve avatar bytes from your own origin, send X-Content-Type-Options: nosniff and a sandboxing CSP such as Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox, so a hostile file can't run as a page on your domain.
⚠️ SECURITY DISCLAIMER
While ens-avatar implements security measures to help protect against XSS and SSRF attacks, you are ultimately responsible for the security of your application. We strongly recommend:
- Conducting your own security audits before deploying to production
- Implementing additional security layers appropriate for your use case
- Keeping the library updated to receive security patches
- Following security best practices when handling user-generated content
- Properly configuring all security-related options for your environment
This library is provided "as-is" without warranty. The maintainers are not liable for any security vulnerabilities in applications using this library.
Demo
-
Create .env file with INFURA_KEY env variable
-
Build the library
-
Node example
node example/node.js ENS_NAME
- Browser example
pnpm build:demo
http-server example