Exporting files to SFTP servers via web browsers
A browser can send a file to an HTTP gateway, which writes it to an SFTP server over SSH. Build that path locally with a disposable OpenSSH server, a pinned host key, and a short-lived access token. You will select a file in the browser and verify the same bytes in a private inbox.
Introduction
This example accepts files from empty through 5 MiB and assigns each a random receipt ID. The browser chooses the bytes; the gateway chooses the server, account, directory, and filename. It exports opaque data without scanning for malware or making documents safe to execute.
Understanding the challenge
Standard web pages cannot open an SSH connection, so browser-to-SFTP uploads need a gateway. The browser authenticates to that gateway with a bearer token. Separately, the gateway verifies the SSH server’s identity and authenticates with its own private key. Keep that key server-side. For a programmatic transfer without a browser, see the Node.js SFTP guide.
Create an isolated local project
Use Linux with Bash, Docker Engine, OpenSSH’s ssh-keygen, Node.js 24.15.0 or a newer Node.js 24
release, and Corepack providing Yarn 4.12.0.
Node.js 24 is a maintained LTS line; this example also
runs on Node.js 26.8.1. The .mts files use Node’s native TypeScript execution, so they do not inherit a parent project’s module type.
Paste this into Bash from the directory where you want the project. The subshell leaves your
current directory and shell options intact on success or failure. A local lockfile and explicit
linker isolate the install from an enclosing Yarn workspace. Existing sftp-upload directories
are refused; an interrupted install can leave a new, partial directory for you to inspect.
(
set -eu
mkdir sftp-upload
cd sftp-upload
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json
touch yarn.lock
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml
mkdir .corepack
printf '%s\n' '{"type":"commonjs"}' > .corepack/package.json
COREPACK_HOME="$PWD/.corepack" YARN_IGNORE_PATH=1 corepack yarn add --exact express@5.2.1 helmet@8.3.0 jose@6.2.12 ssh2-sftp-client@12.1.1 execa@9.6.1
mkdir public
)
Enter the project only after that install succeeds:
cd sftp-upload
Provision the inbox and a development token issuer
Save the following as demo.mts. It builds an Ubuntu 24.04 image with OpenSSH, creates an SFTP-only
account, and exposes SSH on a randomly allocated loopback port. The chroot belongs to root; only
/incoming is writable by the account, with mode 700. OpenSSH requires root-owned chroot
directories.
All credentials are generated for this demo under the private .demo directory. No existing
account, SSH key, or known-hosts file is changed. The host pin is derived from the public key read
through your local Docker daemon, which you control, rather than an unverified SSH connection.
The local RSA key issues five-minute RS256 tokens using jose’s SignJWT.
This development issuer has no login or user directory: never deploy it or share its signing key.
import { createHash, generateKeyPairSync, randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { execa } from 'execa'
import { importPKCS8, SignJWT } from 'jose'
import SftpClient from 'ssh2-sftp-client'
const directory = join(import.meta.dirname, '.demo')
const statePath = join(directory, 'state.json')
const docker = (args, options = {}) => execa('docker', args, { timeout: 180_000, ...options })
async function setup() {
const prefix = process.env.SFTP_DEMO_PREFIX ?? 'sftp-demo'
if (!/^[a-z0-9-]{1,40}$/.test(prefix)) throw new Error('Invalid demo prefix')
const name = prefix + '-' + randomUUID()
await mkdir(directory, { mode: 0o700 }) // Refuse to replace an existing demo.
let ready = false
try {
await mkdir(join(directory, 'staging'), { mode: 0o700 })
await execa('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(directory, 'client')])
const pair = generateKeyPairSync('rsa', { modulusLength: 2048 })
await writeFile(join(directory, 'jwt-private.pem'), pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 })
await writeFile(join(directory, 'jwt-public.pem'), pair.publicKey.export({ type: 'spki', format: 'pem' }), { mode: 0o600 })
const [keyType, keyBytes] = (await readFile(join(directory, 'client.pub'), 'utf8')).split(' ')
const config = `Port 22
HostKey /etc/ssh/ssh_host_ed25519_key
AuthorizedKeysFile /etc/ssh/demo_authorized_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM no
PermitRootLogin no
AllowUsers uploader
Subsystem sftp internal-sftp
Match User uploader
ChrootDirectory /srv/sftp
ForceCommand internal-sftp -u 077
DisableForwarding yes
PermitTTY no`
await docker(['build', '--tag', name, '-'], { input: `FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_*
RUN useradd --no-create-home --home-dir /incoming --shell /usr/sbin/nologin uploader && passwd -d uploader && mkdir -p /run/sshd /srv/sftp/incoming && chown uploader:uploader /srv/sftp/incoming && chmod 700 /srv/sftp/incoming && chmod 755 /srv/sftp
RUN printf '%s\\n' '${keyType} ${keyBytes}' > /etc/ssh/demo_authorized_keys
RUN printf '%s\\n' '${config.replaceAll('\n', "' '")}' > /etc/ssh/sshd_config
CMD ["sh", "-c", "ssh-keygen -A && exec /usr/sbin/sshd -D -e"]
` })
await docker(['run', '--detach', '--rm', '--name', name, '--publish', '127.0.0.1::22', name])
let publicKey
for (let attempt = 0; attempt < 50; attempt++) {
const probe = await docker(['exec', name, 'cat', '/etc/ssh/ssh_host_ed25519_key.pub'], { reject: false })
if (probe.exitCode === 0) { publicKey = probe.stdout; break }
const running = await docker(['inspect', '--format', '{{.State.Running}}', name], { reject: false })
if (running.stdout !== 'true') throw new Error('Disposable SFTP server exited during startup')
await new Promise((resolve) => setTimeout(resolve, 100))
}
if (!publicKey) throw new Error('Disposable SFTP server did not become ready')
const mapping = await docker(['port', name, '22/tcp'])
const port = Number(mapping.stdout.split(':').at(-1))
const hostKey = createHash('sha256').update(Buffer.from(publicKey.split(' ')[1], 'base64')).digest('hex')
const sftp = new SftpClient()
try {
await sftp.connect({ host: '127.0.0.1', port, username: 'uploader', privateKey: await readFile(join(directory, 'client')), hostHash: 'sha256', hostVerifier: (hash) => hash === hostKey, readyTimeout: 10_000 })
if (await sftp.realPath('/incoming') !== '/incoming') throw new Error('Unexpected demo inbox')
} finally { await sftp.end() }
await writeFile(statePath, JSON.stringify({ name, port, hostKey }) + '\n', { mode: 0o600 })
ready = true
console.log('Local SFTP inbox ready. Next: node demo.mts token')
} finally {
if (!ready) {
await docker(['rm', '--force', name], { reject: false })
await docker(['image', 'rm', '--force', name], { reject: false })
await rm(directory, { recursive: true, force: true })
}
}
}
async function main() {
const [command, id, original] = process.argv.slice(2)
if (command === 'setup') return setup()
const state = JSON.parse(await readFile(statePath, 'utf8'))
if (command === 'stop') {
await docker(['rm', '--force', state.name])
await docker(['image', 'rm', '--force', state.name])
await rm(directory, { recursive: true })
console.log('Removed the disposable server, inbox, and demo credentials')
return
}
if (command === 'verify') {
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id ?? '') || !original) throw new Error('Usage: node demo.mts verify RECEIPT_ID ORIGINAL_FILE')
const path = '/srv/sftp/incoming/' + id + '.bin'
const remote = await docker(['exec', state.name, 'cat', path], { encoding: 'buffer', stripFinalNewline: false })
const expected = await readFile(original)
const mode = await docker(['exec', state.name, 'stat', '-c', '%a', path])
if (Buffer.compare(remote.stdout, expected) !== 0 || mode.stdout !== '600') throw new Error('Remote bytes or file permissions differ')
console.log('Verified ' + expected.length + ' bytes; mode 600')
return
}
if (command === 'token') {
const key = await importPKCS8(await readFile(join(directory, 'jwt-private.pem'), 'utf8'), 'RS256')
console.log(await new SignJWT({ scope: 'sftp:upload' }).setProtectedHeader({ alg: 'RS256' }).setIssuer('urn:local-sftp-demo').setAudience('sftp-gateway').setSubject('local-uploader').setIssuedAt().setExpirationTime('5m').sign(key))
return
}
if (command !== 'serve') throw new Error('Use setup, token, serve, verify, or stop')
const port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid HTTP port')
Object.assign(process.env, {
PORT: String(port), APP_ORIGIN: 'http://127.0.0.1:' + port,
JWT_PUBLIC_KEY: await readFile(join(directory, 'jwt-public.pem'), 'utf8'),
JWT_ISSUER: 'urn:local-sftp-demo', JWT_AUDIENCE: 'sftp-gateway',
SFTP_HOST: '127.0.0.1', SFTP_PORT: String(state.port), SFTP_USERNAME: 'uploader',
SFTP_PRIVATE_KEY: await readFile(join(directory, 'client'), 'utf8'), SFTP_HOST_SHA256: state.hostKey,
TMPDIR: join(directory, 'staging'),
})
await import('./index.mts')
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : 'Demo failed')
process.exitCode = 1
})
Setting up the frontend
Save this as public/index.html. The token field is a manual entry point for this local demo.
An application should obtain each authorized user’s short-lived token through its login flow.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SFTP file export</title>
</head>
<body>
<h1>Export a file</h1>
<form id="uploadForm">
<label>Your access token <input id="token" type="password" autocomplete="off" required /></label>
<label>File (up to 5 MiB) <input type="file" id="fileInput" required /></label>
<button type="submit">Export to SFTP</button>
</form>
<p id="status" role="status"></p>
<script src="/upload.js" defer></script>
</body>
</html>
Save this as public/upload.js. The browser sends a File body, including a zero-length file,
and supplies its Content-Length. Controls stay disabled while the request is pending, and
repeat submissions are ignored. The token is cleared from the input before sending it.
const form = document.getElementById('uploadForm')
const token = document.getElementById('token')
const input = document.getElementById('fileInput')
const status = document.getElementById('status')
const button = form.querySelector('button')
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (button.disabled) return
const file = input.files[0]
if (!file || file.size > 5 * 1024 * 1024) {
status.textContent = 'Choose a file of up to 5 MiB.'
return
}
button.disabled = true
input.disabled = true
token.disabled = true
status.textContent = 'Exporting…'
const accessToken = token.value
token.value = ''
try {
const response = await fetch('/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/octet-stream',
},
body: file,
signal: AbortSignal.timeout(35_000),
})
if (!response.ok) throw new Error('Export rejected')
const result = await response.json()
status.textContent = 'File exported. Receipt: ' + result.id
} catch {
status.textContent = 'Export could not be confirmed. Check your inbox before retrying.'
} finally {
button.disabled = false
input.disabled = false
token.disabled = false
}
})
Building the backend API with Node.js
Save this as index.mts. The ssh2-sftp-client API
provides the SFTP streams and rename operation. Its ssh2 connection options
define hostHash and hostVerifier: the pin is a 64-character hexadecimal SHA-256 digest of the
raw host key, not OpenSSH’s SHA256:base64 display string.
The gateway verifies the token’s signature, issuer, audience, age, and upload scope before reading
the body. It stages the complete bounded body locally, writes a private .part file, then renames
it to .bin before returning HTTP 201 and { id }. Consumers must ignore .part files.
import { randomUUID } from 'node:crypto'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { createConnection } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
import express from 'express'
import helmet from 'helmet'
import { importSPKI, jwtVerify } from 'jose'
import SftpClient from 'ssh2-sftp-client'
const {
APP_ORIGIN, JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE,
SFTP_HOST, SFTP_USERNAME, SFTP_PRIVATE_KEY, SFTP_HOST_SHA256,
} = process.env
if (!APP_ORIGIN || !JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE ||
!SFTP_HOST || !SFTP_USERNAME || !SFTP_PRIVATE_KEY ||
!/^[a-f0-9]{64}$/.test(SFTP_HOST_SHA256 ?? '')) {
throw new Error('Gateway configuration is incomplete')
}
const sshPort = Number(process.env.SFTP_PORT ?? 22)
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) {
throw new Error('Invalid SFTP port')
}
const key = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
const webOrigin = new URL(APP_ORIGIN)
const localHttp = webOrigin.protocol === 'http:' &&
['localhost', '127.0.0.1', '[::1]'].includes(webOrigin.hostname)
if (webOrigin.origin !== APP_ORIGIN || (!localHttp && webOrigin.protocol !== 'https:')) {
throw new Error('Use an HTTPS origin or loopback HTTP for local development')
}
const remoteRoot = '/incoming'
const maxBytes = 5 * 1024 * 1024
let active = 0
export const app = express()
app.disable('x-powered-by')
app.use(helmet({
contentSecurityPolicy: {
// The local demo uses HTTP, so its assets must not be upgraded to HTTPS.
directives: { 'upgrade-insecure-requests': localHttp ? null : [] },
},
strictTransportSecurity: localHttp ? false : undefined,
}))
app.use((_req, res, next) => {
res.locals.requestId = randomUUID()
res.set('X-Request-ID', res.locals.requestId)
res.set('Cache-Control', 'no-store')
next()
})
app.post('/upload', async (req, res) => {
const fail = (status, error) => {
if (!res.destroyed && !res.headersSent) {
res.status(status).json({ error, requestId: res.locals.requestId })
}
}
if (req.get('origin') !== APP_ORIGIN || req.originalUrl !== '/upload') {
return fail(403, 'Request not allowed')
}
let claims
try {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.get('authorization') ?? '')
if (!match) return fail(401, 'Authentication required')
const verified = await jwtVerify(match[1], key, {
algorithms: ['RS256'], issuer: JWT_ISSUER, audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'], maxTokenAge: '15m',
})
claims = verified.payload
} catch {
return fail(401, 'Invalid access token')
}
if (typeof claims.scope !== 'string' || !claims.scope.split(' ').includes('sftp:upload')) {
return fail(403, 'Permission denied')
}
const length = req.get('content-length') ?? ''
if (!/^(0|[1-9][0-9]*)$/.test(length)) return fail(411, 'Content length required')
const expected = Number(length)
if (!Number.isSafeInteger(expected) || expected > maxBytes) return fail(413, 'File too large')
if (req.get('content-type') !== 'application/octet-stream') {
return fail(415, 'Send an octet-stream file')
}
if (active >= 2) return fail(503, 'Gateway busy')
active += 1
const id = randomUUID()
const remotePart = remoteRoot + '/' + id + '.part'
const remoteFinal = remoteRoot + '/' + id + '.bin'
const abort = new AbortController()
const timer = setTimeout(() => abort.abort(), 30_000)
const disconnect = () => { if (!res.writableFinished) abort.abort() }
req.once('aborted', disconnect)
res.once('close', disconnect)
let directory
let socket
let connected = false
let pending = false
const sftp = new SftpClient('gateway', {
error: () => abort.abort(),
end: () => abort.abort(),
close: () => abort.abort(),
})
const cancelSocket = () => socket?.destroy()
abort.signal.addEventListener('abort', cancelSocket)
try {
directory = await mkdtemp(join(tmpdir(), 'sftp-upload-'))
const localPath = join(directory, 'payload')
let received = 0
const limit = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
callback(received > expected ? new Error('Byte limit exceeded') : null, chunk)
},
flush(callback) {
callback(received !== expected ? new Error('Incomplete upload') : null)
},
})
await pipeline(req, limit, createWriteStream(localPath, { flags: 'wx', mode: 0o600 }), {
signal: abort.signal,
})
abort.signal.throwIfAborted()
socket = createConnection({ host: SFTP_HOST, port: sshPort })
// ssh2 observes socket failures; this also covers the handoff before its listeners attach.
socket.on('error', () => {})
await sftp.connect({
sock: socket,
username: SFTP_USERNAME,
privateKey: SFTP_PRIVATE_KEY,
hostHash: 'sha256',
hostVerifier: (hash) => hash === SFTP_HOST_SHA256,
readyTimeout: 10_000,
})
connected = true
abort.signal.throwIfAborted()
if (await sftp.realPath(remoteRoot) !== remoteRoot) throw new Error('Unexpected inbox')
pending = true
// The private inbox has no other writers; exclusive creation refuses an existing .part file.
await pipeline(
createReadStream(localPath),
sftp.createWriteStream(remotePart, { flags: 'wx', mode: 0o600 }),
{ signal: abort.signal },
)
abort.signal.throwIfAborted()
await sftp.rename(remotePart, remoteFinal)
pending = false
if (!abort.signal.aborted) res.status(201).json({ id })
} catch {
console.error(JSON.stringify({ event: 'export_failed', requestId: res.locals.requestId }))
fail(502, 'Export could not be confirmed')
} finally {
// A disconnected SSH session cannot guarantee deletion; the inbox janitor handles those parts.
if (connected && pending && !socket.destroyed) {
await sftp.delete(remotePart, true).catch(() => {
console.error(JSON.stringify({ event: 'cleanup_pending', requestId: res.locals.requestId }))
})
}
socket?.destroy()
await sftp.end().catch(() => {})
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {
console.error(JSON.stringify({ event: 'local_cleanup_failed', requestId: res.locals.requestId }))
})
clearTimeout(timer)
abort.signal.removeEventListener('abort', cancelSocket)
req.off('aborted', disconnect)
res.off('close', disconnect)
active -= 1
}
})
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
app.use((_req, res) => res.status(404).json({ error: 'Not found' }))
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).json({ error: 'Request failed' })
})
const httpPort = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(httpPort) || httpPort < 0 || httpPort > 65535) {
throw new Error('Invalid HTTP port')
}
const server = app.listen(httpPort, '127.0.0.1', (error) => {
if (error) {
console.error('Gateway could not bind its HTTP port: ' + error.code)
process.exitCode = 1
return
}
console.log('Open ' + APP_ORIGIN)
})
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Security considerations
The local page uses HTTP on loopback. A deployed page and gateway need HTTPS, the exact configured
Origin, and a real identity provider assigning sftp:upload only to authorized users. Origin
checking complements token verification; it does not authenticate a caller. CORS remains disabled.
The inbox has one trusted writer. No untrusted process may rename /incoming, add symlinks, or
race file creation there. Checking its canonical path does not replace these permissions. Random
names and exclusive staging creation avoid reusing a submitted filename; each retry creates a new ID.
This single process admits two active requests, each with at most 5 MiB of local staging and a 30-second transfer deadline. It streams both transfers instead of holding whole files in memory. The limits cover active requests in one process. Completed remote files and local staging left by a crash or cleanup failure need separate disk quotas and cleanup if you adapt the demo.
A disconnected SSH session can leave a .part file. A lost HTTP response after rename can leave
a complete .bin file even though the browser reports uncertainty. The receipt confirms that
rename succeeded; it is not an exactly-once delivery protocol or a durability guarantee. Inspect
the inbox before retrying an uncertain transfer. Stopping this demo deletes the entire disposable
inbox; a persistent service instead needs a policy for abandoned staging files.
Testing the file export functionality
After saving all four files, provision the server:
node demo.mts setup
Generate a token and copy its output. Keep this terminal private; the token grants upload access until it expires. Run the same command again when you need a fresh token:
node demo.mts token
Start the gateway in the foreground:
node demo.mts serve
Open http://127.0.0.1:3000/, paste the token into Your access token,
choose a file, and select Export to SFTP. While it runs, the page
shows Exporting…. After HTTP 201, it shows
File exported. Receipt: followed by an ID. Opening file:// or
using localhost instead of the printed origin will not work. If port 3000 is occupied, stop the
other service or use PORT=3002 node demo.mts serve and open the printed origin.
In a second terminal, enter the same project directory. Replace RECEIPT_ID with that ID and
ORIGINAL_FILE with the exact file you selected. This command independently reads the private
remote file through Docker, compares its bytes with your original, and checks mode 600:
node demo.mts verify RECEIPT_ID ORIGINAL_FILE
For a seven-byte input, it prints Verified 7 bytes; mode 600. Test an empty file and a binary
file too. A wrong token, issuer, scope, Origin, oversized or interrupted body, wrong host pin, or
unwritable remote inbox must not publish a final file. An upload error response contains a generic
message and a request ID, never SSH credentials or internal error details. The browser reports
Export could not be confirmed. Check your inbox before retrying.
for a rejected transfer or lost confirmation.
Press Ctrl+C in the server terminal to return to the shell. Then remove the demo server, image, inbox, and credentials. This removes only the resources recorded by this project’s setup:
node demo.mts stop
Conclusion
Replace the development issuer and disposable account when integrating this flow into an existing application. Keep the browser authorization boundary and trusted server configuration intact. For processing files before export, see Transloadit’s SFTP export Robot.
