Secure AJAX file uploads with sessions and CSRF checks
An AJAX upload is accepted only when the server has checked the session, permission, request, and file content. This walkthrough gives you a runnable browser form and Node.js server that enforce those checks, report upload progress, and let each user download only their own accepted files.
Define what the server accepts
The example uploads JSON note attachments, not arbitrary images or documents. Each file must
contain a UTF-8 JSON object with exactly one property, message, whose value is a nonblank string.
Save this as note.json when trying the form:
{"message":"Hello from an AJAX upload."}
The server accepts exactly one multipart field named file, no extra fields, and a file of at most
64 KiB. It also caps the entire multipart body at 80 KiB, including boundaries and headers. Both
limits are enforced on received bytes. Renaming a PNG to note.json does not make it valid JSON.
Conversely, valid note content named note.png, or sent with an incorrect MIME type, is accepted:
this example deliberately ignores both pieces of client metadata and downloads every accepted file
as note.json with application/json.
This is a content policy for a specific application. Parsing JSON does not prove a file is free of malware, and a string containing HTML remains untrusted data. The example never renders that string. For broader file types, choose separate validation and processing rules; a MIME allowlist or file signature alone is insufficient. See OWASP’s upload guidance.
Set up the local demo
Use Node.js 26 and a current browser. The example below was tested on Linux with Node.js 26.8.1 and Chromium 152. It has no package dependencies. Create a new directory from a POSIX shell:
mkdir ajax-upload-demo && cd ajax-upload-demo
If that command fails, stop and choose a new directory; do not overwrite an existing project. Save
the next three files inside it: server.ts, index.html, and client.js.
The server binds only to 127.0.0.1 on an available port and prints that URL plus fresh passwords for
alice and bob. These are disposable local identities. Anyone with the printed password can act
as that user. Each login replaces that user’s previous session, and sessions expire after 15 minutes.
Uploads remain in memory until the process exits, with a maximum of ten files per user. Repeated
uploads create separate entries; they never replace an earlier attachment.
Enforce the rules on the server
Save this as server.ts. The only public files are the two explicitly served client assets.
Authentication and the session’s CSRF token are checked before reading an upload body. Downloads
look up the ID in the authenticated user’s own map, so another user receives the same 404 as for
an unknown ID.
import { randomBytes, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface DemoUser {
name: string
password: string
session: string
csrf: string
expires: number
files: Map<string, Buffer>
}
const token = (): string => randomBytes(32).toString('hex')
const users: DemoUser[] = ['alice', 'bob'].map((name) => ({
name, password: token(), session: '', csrf: '', expires: 0, files: new Map(),
}))
const html = await readFile(new URL('./index.html', import.meta.url))
const script = await readFile(new URL('./client.js', import.meta.url))
let origin = ''
let cookieName = ''
class Rejection extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
let size = 0
// Keep the socket writable so an oversized request can receive a 413 response.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > 80 * 1024) throw new Rejection(413, 'Request exceeds 80 KiB.')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
async function noteBytes(req: IncomingMessage): Promise<Buffer> {
const body = await readBody(req)
const contentType = req.headers['content-type'] ?? ''
if (!/^multipart\/form-data\s*;/i.test(contentType)) {
throw new Rejection(415, 'Use multipart/form-data.')
}
let form: FormData
try {
form = await new Response(new Uint8Array(body), {
headers: { 'Content-Type': contentType },
}).formData()
} catch {
throw new Rejection(400, 'Malformed multipart body.')
}
const entries = [...form.entries()]
const file = form.get('file')
if (entries.length !== 1 || !(file instanceof File)) {
throw new Rejection(400, 'Send exactly one file field and no other fields.')
}
if (file.size > 64 * 1024) throw new Rejection(413, 'File exceeds 64 KiB.')
const bytes = Buffer.from(await file.arrayBuffer())
let value: unknown
try {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
} catch {
throw new Rejection(422, 'File must contain a UTF-8 JSON note.')
}
if (
typeof value !== 'object' || value === null || Array.isArray(value) ||
Object.keys(value).length !== 1 || !('message' in value) ||
typeof value.message !== 'string' || value.message.trim().length === 0
) {
throw new Rejection(422, 'Use an object with one nonblank message string.')
}
return bytes
}
function sessionData(user: DemoUser): unknown {
return {
user: user.name,
csrf: user.csrf,
files: [...user.files].map(([id, bytes]) => ({ id, bytes: bytes.length })),
}
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'GET' && (req.url === '/' || req.url === '/client.js')) {
res.setHeader('Content-Type', req.url === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(req.url === '/' ? html : script)
return
}
if (req.method === 'POST' && req.headers.origin !== origin) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'POST' && req.url?.startsWith('/login/')) {
const user = users.find((entry) => `/login/${entry.name}` === req.url)
if (!user || req.headers['x-demo-password'] !== user.password) {
throw new Rejection(401, 'Invalid demo credentials.')
}
user.session = token()
user.csrf = token()
user.expires = Date.now() + 15 * 60 * 1000
res.setHeader('Set-Cookie',
`${cookieName}=${user.session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=900`)
json(res, 200, sessionData(user))
return
}
const session = req.headers.cookie?.split(';').map((part) => part.trim())
.find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1)
const user = users.find((entry) => entry.session === session && entry.expires > Date.now())
if (!user) throw new Rejection(401, 'Log in again.')
if (req.method === 'GET' && req.url === '/session') {
json(res, 200, sessionData(user))
return
}
if (req.method === 'GET' && req.url?.startsWith('/files/')) {
const bytes = user.files.get(req.url.slice('/files/'.length))
if (!bytes) throw new Rejection(404, 'File not found.')
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="note.json"',
})
res.end(bytes)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
throw new Rejection(404, 'Route not found.')
}
if (req.headers['x-csrf-token'] !== user.csrf) {
throw new Rejection(403, 'Refresh your session before uploading.')
}
const bytes = await noteBytes(req)
// A second login or session expiry during transfer must invalidate this request too.
if (user.session !== session || user.expires <= Date.now()) {
throw new Rejection(401, 'Log in again.')
}
if (user.files.size >= 10) throw new Rejection(409, 'Demo storage is full. Restart to clear it.')
const id = randomUUID()
user.files.set(id, bytes)
json(res, 201, { id, bytes: bytes.length })
}
const server = createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
void handle(req, res).catch((error: unknown) => {
const status = error instanceof Rejection ? error.status : 500
const message = error instanceof Rejection ? error.message : 'Unable to handle the request.'
if (status === 500) console.error('Request failed unexpectedly.')
res.setHeader('Connection', 'close')
json(res, status, { error: message })
req.resume()
})
})
server.maxConnections = 16
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
cookieName = `ajax_demo_${address.port}`
console.log(`Open ${origin}`)
for (const user of users) console.log(`${user.name} password: ${user.password}`)
})
The body limit is applied before Node’s multipart parser buffers the individual fields. The
destroyOnReturn: false option lets
an early size rejection send its HTTP response before closing the connection. Nothing is inserted
into storage until every validation succeeds; rejected bodies have no retained entry or disk file.
The token returned by /session is separate from the HttpOnly session cookie. The browser sends
it in X-CSRF-Token, and the server compares it with the token belonging to that session. An exact
Origin check also protects POST requests, including login. No CORS access is granted. These
choices follow the synchronizer-token pattern;
SameSite adds another layer but does not replace the token check.
Add the browser form
Save this as index.html. Use the native file picker and submit buttons so the form works with a
keyboard. This example intentionally selects one file at a time; adding drag-and-drop or a batch
queue should preserve the same server contract.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AJAX note upload</title>
<script type="module" src="/client.js"></script>
</head>
<body>
<h1>Upload a private JSON note</h1>
<p>Files live in server memory until restart. Select one JSON note, up to 64 KiB.</p>
<fieldset id="controls">
<legend>Demo session and upload</legend>
<form id="login">
<label for="user">Demo user</label>
<select id="user"><option>alice</option><option>bob</option></select>
<label for="password">Password from the server terminal</label>
<input id="password" type="password" autocomplete="current-password" required />
<button>Log in</button>
</form>
<form id="upload">
<label for="file">JSON note</label>
<input id="file" type="file" accept=".json,application/json" required />
<button>Upload</button>
</form>
<button id="refresh" type="button">Refresh accepted files</button>
</fieldset>
<p id="identity">Not logged in.</p>
<label id="progress-label" for="progress">Request bytes transferred</label>
<progress id="progress" aria-labelledby="progress-label" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite"></p>
<h2>Your accepted files</h2>
<ul id="files"></ul>
</body>
</html>
Send the file and wait for acceptance
Save this as client.js. Fetch handles the session requests; XMLHttpRequest handles the upload
because it exposes request upload progress.
A full progress bar means the request body was sent, not that the server accepted the file. Only
HTTP 201 from /upload creates a download link.
const controls = document.getElementById('controls')
const login = document.getElementById('login')
const upload = document.getElementById('upload')
const user = document.getElementById('user')
const password = document.getElementById('password')
const fileInput = document.getElementById('file')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const identity = document.getElementById('identity')
const files = document.getElementById('files')
let csrf = ''
let busy = false
function failure(code) {
const messages = {
400: 'Send exactly one file and no extra fields.',
401: 'Log in with the password from the server terminal.',
403: 'Session check failed. Refresh accepted files or log in again.',
409: 'Demo storage is full. Restart the server to clear it.',
413: 'Upload exceeds a size limit. Choose a smaller file.',
415: 'The server requires multipart form data.',
422: 'Choose a UTF-8 JSON object with one nonblank message string.',
}
return new Error(messages[code] ?? 'Acceptance is unconfirmed. Refresh accepted files before retrying.')
}
async function run(action) {
if (busy) return
busy = true
controls.disabled = true
try {
await action()
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Request failed.'
} finally {
busy = false
controls.disabled = false
}
}
function addFile(file) {
const item = document.createElement('li')
const link = document.createElement('a')
link.href = `/files/${encodeURIComponent(file.id)}`
link.textContent = `Download ${file.id} (${file.bytes.toLocaleString()} bytes)`
item.append(link)
files.append(item)
}
async function loadSession(response) {
if (!response.ok) {
if (response.status === 401) {
csrf = ''
identity.textContent = 'Not logged in.'
files.replaceChildren()
}
throw failure(response.status)
}
const data = await response.json()
csrf = data.csrf
identity.textContent = `Logged in as ${data.user}.`
files.replaceChildren()
for (const file of data.files) addFile(file)
}
login.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const response = await fetch(`/login/${encodeURIComponent(user.value)}`, {
method: 'POST',
headers: { 'X-Demo-Password': password.value },
credentials: 'same-origin',
})
password.value = ''
await loadSession(response)
status.textContent = 'Logged in. Choose a note to upload.'
})
})
function sendFile(file) {
return new Promise((resolve, reject) => {
const body = new FormData()
body.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress.value = event.loaded / event.total * 100
}
xhr.upload.onload = () => { status.textContent = 'Transferred. Waiting for server acceptance…' }
xhr.open('POST', '/upload')
xhr.setRequestHeader('X-CSRF-Token', csrf)
xhr.responseType = 'json'
xhr.timeout = 45_000
xhr.onload = () => {
if (xhr.status === 201 && typeof xhr.response?.id === 'string') resolve(xhr.response)
else reject(failure(xhr.status))
}
xhr.onerror = xhr.ontimeout = xhr.onabort = () => reject(failure(0))
xhr.send(body)
})
}
upload.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const file = fileInput.files[0]
if (!csrf) throw failure(401)
if (!file) throw new Error('Choose a file first.')
if (file.size > 64 * 1024) throw failure(413)
progress.value = 0
status.textContent = 'Uploading…'
const accepted = await sendFile(file)
addFile(accepted)
status.textContent = 'Accepted into private memory. Use the download link to check the bytes.'
})
})
async function refresh() {
await loadSession(await fetch('/session', { credentials: 'same-origin' }))
status.textContent = 'Accepted file list refreshed.'
}
document.getElementById('refresh').addEventListener('click', () => { void run(refresh) })
void run(refresh)
Let the browser set Content-Type when sending FormData: it must include the generated multipart
boundary. MDN explains why setting it manually breaks the request.
The picker’s accept value and the client’s size check provide early feedback; neither is a server
security control. While any request is pending, the fieldset is disabled and a busy guard ignores
repeated submissions. The selected File is captured before the upload begins.
Try acceptance and rejection
From the directory containing all three files, start the server:
node server.ts
Open the exact printed URL, log in as alice, select note.json, and activate Upload. After
“Accepted into private memory,” use the download link. It returns the original bytes, including
whitespace, as an attachment. Your browser decides whether to prompt for a destination or choose a
new filename when note.json already exists; clicking the link alone does not confirm a saved file.
Try a file containing {"message":42}: the bar may fill, but the server returns 422, the page
explains the required content, and the accepted list gains no entry. In a separate private browser
window, log in as bob and open Alice’s download URL. It returns 404. Without a session it returns
401. The server’s application errors contain fixed messages rather than parser details, paths, or
submitted content.
| Response | Meaning and next action |
|---|---|
201 | Accepted and retained in this process; available to its owner. |
400 / 415 | Fix the multipart request or its fields. |
401 / 403 | Restore the session or CSRF evidence before another upload. |
413 | A fixed size limit was exceeded. Choose a smaller file. |
422 | Correct the file content. |
409 | Ten files are already stored for this user. Restarting clears all demo data. |
| Network error, timeout, or other status | Acceptance is unconfirmed. Refresh the accepted list before deciding what to do. |
There are no automatic retries, including for 413 or a lost response. If the server stored the
file but the response was lost, refreshing reveals the new entry; download it to identify its
bytes. Manually uploading it again creates a second ID. Production retries require a persistent,
user-scoped deduplication contract before they can safely repeat an upload. This server does not
advertise transient failures or Retry-After.
Connect the example to your application
Stop the demo with Ctrl+C; accepted files, passwords, and sessions are discarded. For deployment,
replace the local identities and memory maps with your application’s authentication, authorization,
CSRF middleware, and private durable storage. Use HTTPS and a Secure session cookie. The demo’s
HTTP cookie is restricted to this loopback exercise; cookie attributes have distinct roles.
Set deployment-specific rate and concurrency limits as well as storage quotas. The demo’s request
size and connection caps do not replace those controls.
Larger files need a streaming parser and storage path instead of this bounded in-memory parser. If you need resumability, use a tus server and compatible client. Splitting a file into chunks alone does not implement authorization, reassembly, cleanup, or safe repeated requests; this example intentionally has no chunk endpoints.
