Téléverser vers Cloudflare R2 par navigateur avec le SDK AWS
Un navigateur peut téléverser directement vers un bucket Cloudflare R2 privé sans recevoir votre clé secrète R2. Exécutez la petite application locale ci-dessous : elle authentifie la personne qui demande un téléversement, signe une seule clé d’objet et confirme les octets stockés avant d’afficher la réussite de l’opération.
Garder le contrôle des autorisations sur le serveur
Le SDK AWS s’exécute sur votre serveur Node.js. Le navigateur reçoit une URL PUT présignée pour un seul objet, envoie le fichier à R2, puis demande au serveur de le relire. Comparer la taille et l’empreinte SHA-256 des données stockées à celles du fichier sélectionné permet de détecter un transfert HTTP réussi ayant stocké des octets incorrects.
Cette démonstration locale est destinée à un seul utilisateur et à des fichiers de zéro octet à 10 MiB. Son code d’accès identifie un utilisateur local unique ; le serveur choisit ses clés et conserve au maximum huit attributions en mémoire. Un redémarrage fait perdre ces enregistrements, mais ne supprime pas les objets. Une application déployée doit disposer de ses propres sessions utilisateur et d’enregistrements persistants de propriété à la place de ce code d’accès local.
Une URL présignée confère une autorisation à quiconque la détient et reste réutilisable jusqu’à son expiration. Une personne qui l’obtient peut l’utiliser sans le code d’accès de l’application. Gardez ces URL hors des journaux, des outils d’analyse et des messages partagés ; cet exemple ne les affiche pas et ne transmet pas l’en-tête d’autorisation de l’application à R2.
Préparer un bucket privé et un projet local
Utilisez un bucket R2 privé sous votre contrôle, dans la juridiction par défaut, avec l’accès public désactivé. Vous avez besoin de son nom, de l’identifiant du compte Cloudflare, ainsi que d’un identifiant de clé d’accès S3 pour R2 et de la clé d’accès secrète correspondante, avec l’autorisation Object Read & Write limitée à ce bucket. Suivez le guide d’authentification R2 de Cloudflare pour obtenir ces valeurs. Une connexion Wrangler ou une liaison de bucket dans un Worker ne fournit pas de paire de clés S3.
Les commandes utilisent Bash sous Linux, Node.js 26.12.0 (la version Current testée) et Corepack 0.36.0, installé séparément. Node.js 26 n’inclut pas Corepack. Pour une nouvelle installation, utilisez une version corrective maintenue de Node.js 26 ; les versions exactes des paquets ci-dessous rendent cet exemple reproductible. Les versions figées du SDK étaient les plus récentes admises par le seuil d’ancienneté des paquets de deux jours du projet lors des tests. Le navigateur doit disposer de la File API et de Web Crypto, disponibles dans Chromium sur cette origine de bouclage.
Depuis un répertoire accessible en écriture, collez ce bloc. Il crée un nouveau projet et laisse votre shell dans son répertoire d’origine. Si le répertoire existe déjà, le bloc s’arrête sans le remplacer. En cas d’échec de l’installation, le nouveau répertoire est conservé pour inspection ; choisissez un autre emplacement vide pour réessayer depuis le début.
(
set -eu
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
node -e 'if (Number(process.versions.node.split(".")[0]) !== 26) process.exit(1)'
mkdir r2-browser-demo
cd r2-browser-demo
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.18.1"}' > package.json
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'ignorePath: true' 'npmMinimalAgeGate: 2d' > .yarnrc.yml
touch yarn.lock
corepack yarn add --exact @aws-sdk/client-s3@3.1150.0 @aws-sdk/s3-request-presigner@3.1150.0 zod@4.6.7
)
Enregistrez les trois fichiers suivants dans r2-browser-demo. La configuration locale de Yarn sélectionne explicitement
le mode classique node_modules et un cache propre au projet, même lorsqu’un projet englobant utilise Plug’n’Play.
Configurer CORS pour le navigateur
Dans les paramètres de votre bucket, saisissez cette politique à l’aide de l’éditeur JSON du tableau de bord. Ne remplacez une politique existante que pour un bucket dédié à la démonstration ; sinon, fusionnez cette règle avec celles dont votre application a déjà besoin. Elle autorise l’origine exacte affichée par le serveur local, et non tous les noms associés à localhost.
[
{
"AllowedOrigins": ["http://127.0.0.1:8080"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"]
}
]
La configuration CORS du bucket autorise la requête PUT interorigine du navigateur. Elle n’authentifie pas la personne qui demande une autorisation à votre application. L’URL signée fonctionne toujours dans un client HTTP qui n’applique pas CORS ; limiter les origines ne peut donc pas remplacer l’autorisation côté serveur.
Enregistrer le service de signature et de vérification des octets
Enregistrez ce fichier sous storage.mts. Le client utilise le point de terminaison S3 standard de R2 et des données d’accès
explicites ; il ne recherche donc pas votre configuration AWS personnelle.
WHEN_REQUIRED désactive ici le calcul facultatif des sommes de contrôle des requêtes par le SDK ;
cet exemple vérifie lui-même l’intégralité du corps stocké au lieu de considérer un ETag comme une
empreinte SHA-256. AWS documente ce paramètre de somme de
contrôle.
// storage.mts
import { randomUUID } from 'node:crypto'
import { S3Client } from '@aws-sdk/client-s3'
import { z } from 'zod'
const parsed = z.object({
CLOUDFLARE_ACCOUNT_ID: z.string().regex(/^[a-f0-9]{32}$/),
R2_BUCKET_NAME: z.string().min(3).max(63),
R2_ACCESS_KEY_ID: z.string().min(1),
R2_SECRET_ACCESS_KEY: z.string().min(1),
UPLOAD_TOKEN: z.string().min(32).max(256),
PORT: z.coerce.number().int().min(1024).max(65535).default(8080),
}).safeParse(process.env)
if (!parsed.success) throw new Error('Set all five configuration values; use a 32+ character access code.')
export const config = parsed.data
export const origin = `http://127.0.0.1:${config.PORT}`
export const prefix = `browser-demo/${randomUUID()}/`
export const maxBytes = 10 * 1024 * 1024
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${config.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
})
Enregistrez ce fichier sous server.mts. Seule une requête authentifiée provenant de la page locale peut réserver un téléversement.
Ni la clé ni le propriétaire ne proviennent d’une saisie dans le navigateur. La vérification retrouve
l’objet associé à l’identifiant opaque grâce au même enregistrement de propriété ; un appelant ne
peut donc pas amener le serveur à lire un objet arbitraire du bucket.
// server.mts
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { z } from 'zod'
import { config, maxBytes, origin, prefix, r2 } from './storage.mts'
const inputSchema = z.object({
size: z.number().int().min(0).max(maxBytes),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
}).strict()
const allocations = new Map<string, { owner: string; key: string; size: number; sha256: string }>()
const page = `<!doctype html><html lang="en"><meta charset="utf-8">
<title>R2 browser upload</title><main><h1>R2 browser upload</h1>
<form><fieldset>
<label>Local access code <input id="accessCode" type="password" autocomplete="off" required></label>
<label>File <input id="file" type="file" required></label>
<button>Upload</button>
</fieldset></form><p role="status" aria-live="polite">Choose a file, including an empty file.</p>
<script type="module" src="/uploader.js"></script></main></html>`
function reply(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function json(req: IncomingMessage): Promise<unknown> {
const parts: Buffer[] = []
let length = 0
for await (const part of req) {
length += part.length
if (length > 4096) throw new Error('Request too large')
parts.push(part)
}
return JSON.parse(Buffer.concat(parts).toString('utf8'))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
if (req.headers.host !== new URL(origin).host) return reply(res, 403, { error: 'Use the printed local address.' })
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(page)
return
}
if (req.method === 'GET' && req.url === '/uploader.js') {
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' })
res.end(await readFile(new URL('./uploader.ts', import.meta.url)))
return
}
if (!req.url?.startsWith('/api/')) return reply(res, 404, { error: 'Not found.' })
const supplied = Buffer.from(req.headers.authorization ?? '')
const expected = Buffer.from(`Bearer ${config.UPLOAD_TOKEN}`)
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
return reply(res, 401, { error: 'Access code required.' })
}
if (req.headers.origin !== origin) return reply(res, 403, { error: 'Origin refused.' })
const owner = 'local-user'
if (req.method === 'POST' && req.url === '/api/uploads') {
const input = inputSchema.safeParse(await json(req))
if (!input.success) return reply(res, 400, { error: 'Expected size 0–10 MiB and a SHA-256 digest.' })
if (allocations.size >= 8) return reply(res, 429, { error: 'Eight allocations used. Restart for a new session.' })
const id = randomUUID()
const key = `${prefix}${owner}/${id}`
allocations.set(id, { owner, key, ...input.data })
// Log only the opaque key, so failed uploads can be found and removed.
console.info(`Allocated key: ${key}`)
const url = await getSignedUrl(r2, new PutObjectCommand({
Bucket: config.R2_BUCKET_NAME, Key: key, ContentType: 'application/octet-stream',
}), { expiresIn: 120, signableHeaders: new Set(['content-type']) })
return reply(res, 200, { id, key, url })
}
const match = /^\/api\/uploads\/([a-f0-9-]{36})\/verify$/.exec(req.url)
const allocation = match ? allocations.get(match[1]) : undefined
if (req.method !== 'POST' || !allocation || allocation.owner !== owner) {
return reply(res, 404, { error: 'Upload not found in this session.' })
}
const object = await r2.send(new GetObjectCommand({
Bucket: config.R2_BUCKET_NAME, Key: allocation.key,
}))
if (!object.Body) throw new Error('Missing stored body')
const reader = object.Body.transformToWebStream().getReader()
const hash = createHash('sha256')
let size = 0
try {
for (;;) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.length
if (size > maxBytes) return reply(res, 409, { error: 'Stored object exceeds 10 MiB.' })
hash.update(chunk.value)
}
} finally {
await reader.cancel()
reader.releaseLock()
}
const sha256 = hash.digest('hex')
if (size !== allocation.size || sha256 !== allocation.sha256) {
return reply(res, 409, { error: 'Stored bytes differ from the selected file.' })
}
reply(res, 200, { key: allocation.key, bytes: size, sha256 })
}
const server = createServer((req, res) => {
handle(req, res).catch(() => {
console.error('Upload request failed. Check local configuration and R2 access.')
if (!res.headersSent) reply(res, 500, { error: 'Upload request failed.' })
else res.end()
})
})
server.on('error', () => {
console.error('Cannot start the upload server. Check whether the port is already in use.')
r2.destroy()
process.exitCode = 1
})
server.listen(config.PORT, '127.0.0.1', () => console.info(`Open ${origin}`))
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
server.close()
server.closeAllConnections()
r2.destroy()
})
}
L’URL PUT reste valide pendant 120 secondes et sa signature couvre Content-Type: application/octet-stream. Le navigateur doit
envoyer cette valeur exacte. Ce choix stocke délibérément des octets de fichier arbitraires avec un
type de média générique ; l’en-tête constitue une métadonnée, pas une preuve du format ou de
l’innocuité du fichier. Les contrôles à 10 MiB limitent le parcours normal de l’application et le
vérificateur, mais pas la taille qu’un détenteur malveillant pourrait envoyer par PUT avec une URL
divulguée.
Enregistrer l’outil de téléversement du navigateur
Enregistrez ce fichier sous uploader.ts. Il utilise la syntaxe JavaScript pour que le serveur puisse le fournir directement comme
module, sans étape de compilation. Les contrôles du formulaire restent désactivés
jusqu’à la fin de la lecture finale de vérification des octets téléversés,
et une seconde soumission est ignorée tant que le traitement est en cours.
// uploader.ts: JavaScript syntax, served directly to the browser.
const form = document.querySelector('form')
const fields = document.querySelector('fieldset')
const accessCode = document.querySelector('#accessCode')
const input = document.querySelector('#file')
const status = document.querySelector('[role="status"]')
if (!(form instanceof HTMLFormElement) || !(fields instanceof HTMLFieldSetElement) ||
!(accessCode instanceof HTMLInputElement) || !(input instanceof HTMLInputElement) ||
!(status instanceof HTMLElement)) throw new Error('Upload controls are missing')
let busy = false
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (busy) return
const file = input.files?.[0]
if (!file) return
if (file.size > 10 * 1024 * 1024) {
status.textContent = 'Choose a file of at most 10 MiB.'
return
}
busy = true
fields.disabled = true
let key = ''
const authorization = `Bearer ${accessCode.value}`
try {
status.textContent = 'Preparing upload…'
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
const permission = await fetch('/api/uploads', {
method: 'POST',
headers: { Authorization: authorization, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: file.size, sha256 }),
})
if (!permission.ok) throw new Error(`Permission refused (${permission.status}).`)
const allocation = await permission.json()
key = allocation.key
status.textContent = 'Uploading…'
const uploaded = await fetch(allocation.url, {
method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' },
credentials: 'omit', referrerPolicy: 'no-referrer',
})
if (!uploaded.ok) throw new Error(`R2 refused the upload (${uploaded.status}).`)
status.textContent = 'Verifying stored bytes…'
const verification = await fetch(`/api/uploads/${allocation.id}/verify`, {
method: 'POST', headers: { Authorization: authorization },
})
if (!verification.ok) throw new Error('Stored bytes could not be verified.')
const receipt = await verification.json()
if (receipt.key !== key || receipt.bytes !== file.size || receipt.sha256 !== sha256) {
throw new Error('Stored bytes could not be verified.')
}
status.textContent = `Verified ${receipt.bytes.toLocaleString()} bytes. Key: ${key}`
} catch (error) {
const message = error instanceof Error && /^(Permission refused|R2 refused|Stored bytes)/.test(error.message)
? error.message : 'Upload did not complete. Check the connection and bucket CORS.'
status.textContent = `${message}${key ? ` Key: ${key}` : ''}`
} finally {
busy = false
fields.disabled = false
}
})
Démarrer l’application et téléverser un fichier
Depuis le même répertoire parent que celui utilisé pour la préparation, collez ce bloc dans un terminal. Les saisies secrètes sont masquées et le sous-shell les garde hors de l’environnement du shell parent. Choisissez un code d’accès local long que vous pourrez saisir à nouveau dans la page ; il est distinct de vos données d’accès R2.
(
set -eu
cd r2-browser-demo
read -r -p 'Cloudflare account ID: ' CLOUDFLARE_ACCOUNT_ID
read -r -p 'R2 bucket name: ' R2_BUCKET_NAME
read -r -s -p 'R2 access key ID: ' R2_ACCESS_KEY_ID; printf '\n'
read -r -s -p 'R2 secret access key: ' R2_SECRET_ACCESS_KEY; printf '\n'
read -r -s -p 'Local access code (32+ characters): ' UPLOAD_TOKEN; printf '\n'
export CLOUDFLARE_ACCOUNT_ID R2_BUCKET_NAME R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY UPLOAD_TOKEN
node server.mts
)
Ouvrez http://127.0.0.1:8080, saisissez le code dans Local access code,
choisissez un fichier avec File, puis sélectionnez Upload.
Le statut final commence par Verified et indique le nombre d’octets ainsi que
la clé de l’objet. Ce message n’apparaît qu’après la lecture des données R2 par le serveur et la
vérification, des deux côtés, de la taille et de l’empreinte attendues. Essayez avec un fichier texte,
un fichier binaire et un fichier vide ; les noms ne servent pas de clés d’objet.
Chaque clic après la fin du traitement attribue une nouvelle clé UUID. Il ne remplace pas un précédent téléversement. Le serveur affiche les clés attribuées pour que vous puissiez retrouver même un téléversement interrompu. Après vos tests, attendez au moins 120 secondes après la dernière attribution, arrêtez le serveur avec Ctrl+C, puis supprimez précisément ces objets de votre bucket. L’arrêt du serveur ne révoque pas les URL déjà émises et ne supprime pas les fichiers.
Diagnostiquer un échec de téléversement
Si l’autorisation est refusée avec le code 401, vérifiez le code d’accès local. Un code 400 signifie que le service de signature n’a pas reçu une taille et une empreinte valides ; un code 429 signifie que ce processus a utilisé ses huit attributions. Un redémarrage permet une nouvelle session ; cette limite sert donc à borner une démonstration locale, et ne constitue pas un quota de production.
En cas d’erreur réseau du navigateur, examinez la phase en échec dans ses outils réseau. L’échec
d’une requête préliminaire OPTIONS oriente vers l’origine, la méthode et les en-têtes autorisés par
le bucket. Vérifiez le port et l’orthographe de 127.0.0.1 avant de modifier la politique. Un refus
lié à la signature est un échec d’autorisation R2 distinct ; modifier le type de contenu signé
invalide l’autorisation. Les URL expirées sont également refusées, et leurs réponses peuvent omettre
les en-têtes CORS ; le JavaScript du navigateur peut alors ne voir qu’une erreur réseau.
Cloudflare décrit cette distinction dans son guide CORS.
Un échec de vérification signifie que l’application n’a pas confirmé les octets sélectionnés. Inspectez la clé attribuée avant de réessayer : une réponse en échec ou perdue peut laisser un objet stocké. Une nouvelle tentative crée une nouvelle clé et renvoie le fichier entier. Cet exemple ne revendique ni nouvelle tentative automatique ni reprise de téléversement.
Choisir la suite pour votre application
Gardez les fichiers plus volumineux hors du périmètre de cette démonstration, limité à 10 MiB. Le téléversement multipartie nécessite un cycle de vie authentifié distinct pour démarrer les téléversements, signer leurs parties, les terminer et les abandonner ; il n’est pas implémenté ici. Un Worker peut héberger la logique applicative, mais le parcours exécutable de ce guide repose sur le service de signature Node.js local. Les URL présignées utilisent le nom d’hôte S3 de R2 et ne peuvent pas utiliser de domaines personnalisés.
Pour une interface de téléversement plus riche, découvrez Uppy. La connexion d’un outil de téléversement à votre application nécessite toujours la frontière de confiance côté serveur en matière de propriété et de signature illustrée ici.
