Bridge SFTP to browsers with WebAssembly and WebSockets
Use a Node.js gateway to let a browser list and download a small, authorized SFTP catalog while keeping SSH credentials on the server. This walkthrough includes a disposable OpenSSH server and local token issuer, so you can try the complete browser-to-SFTP path on your own machine.
Choose where SSH terminates
SFTP normally runs inside SSH over TCP. A browser WebSocket uses an HTTP handshake and carries WebSocket frames; connecting it to port 22 does not make it an SSH socket. WebAssembly can run SSH code, but it does not give a standard web page native TCP sockets.
With browser-side SSH, a transport proxy supplies network reachability while the browser
authenticates the SSH host and user. With an HTTP API gateway, SSH ends on the gateway, which
authorizes each application operation. The example below uses the second design: a fixed catalog
with one public file ID, no browser-controlled paths or destinations, and a required sftp:read scope.
Create the gateway project
The local example was tested on Linux with Node.js 24.15.0
and 26.8.1, Yarn 4.12.0, OpenSSH 9.2, and Chromium 152.
This recipe targets maintained Node 24 or 26 releases; those patches record the tested versions.
The launcher uses Node’s built-in TypeScript support. Install
Docker Engine and the OpenSSH client tools separately,
and ensure node, curl, docker, and ssh-keygen are on your PATH.
The launcher uses the local Docker socket at /var/run/docker.sock; it never starts a host SSH daemon.
Paste this into a POSIX shell. It creates a new directory and uses a private Yarn cache and lockfile,
including when the parent is a Yarn project. The subshell leaves your current directory and options
unchanged. An existing sftp-browser-demo directory is refused.
(
command -v node >/dev/null && command -v curl >/dev/null &&
command -v docker >/dev/null && command -v ssh-keygen >/dev/null &&
mkdir sftp-browser-demo && cd sftp-browser-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"express":"5.2.1","helmet":"8.3.0","jose":"6.2.12","ssh2-sftp-client":"12.1.1"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' 'cacheFolder: .yarn/cache' 'globalFolder: .yarn/global' 'installStatePath: .yarn/install-state.gz' 'enableTelemetry: false' 'injectEnvironmentFiles: []' > .yarnrc.yml &&
touch yarn.lock &&
curl -fsSLo yarn-4.12.0.cjs https://repo.yarnpkg.com/4.12.0/packages/yarnpkg-cli/bin/yarn.js &&
node yarn-4.12.0.cjs install
)
Create all the following files inside sftp-browser-demo. If installation fails, keep its package
files, fix the reported dependency or network failure, and retry installation from the parent directory:
(cd sftp-browser-demo &&
curl -fsSLo yarn-4.12.0.cjs https://repo.yarnpkg.com/4.12.0/packages/yarnpkg-cli/bin/yarn.js &&
node yarn-4.12.0.cjs install)
The retry also downloads the CLI again if its first download failed. The CLI is saved as .cjs
so an enclosing ES-module project cannot reinterpret it.
Authorize and read the fixed catalog
Save this as index.mjs. The pinned SFTP library
provides the canonical-path, file-stat, and stream APIs. Files may be empty and are limited to 5 MiB.
The gateway allows two active downloads per process. Each download gets 30 seconds for its SFTP
connection, read, and HTTP delivery. It buffers the bounded file before sending HTTP success,
so an SFTP error after partial progress remains a failed download.
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
import { Readable, Transform, Writable } 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 catalog = new Map([
['report', { label: 'Sample file', path: '/exports/report.bin' }],
])
const maxBytes = 5 * 1024 * 1024
let active = 0
export const app = express()
app.disable('x-powered-by')
app.use(helmet({
contentSecurityPolicy: {
// Keep local Safari from upgrading this demo's HTTP assets 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.use('/files', async (req, res, next) => {
// Same-origin fetches may omit Origin on GET. Require a custom header and disable CORS.
const origin = req.get('origin')
if ((origin && origin !== APP_ORIGIN) || req.get('X-SFTP-Client') !== 'browser' ||
req.get('sec-fetch-site') === 'cross-site' || Object.keys(req.query).length !== 0) {
return res.status(403).json({ error: 'Request not allowed' })
}
try {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.get('authorization') ?? '')
if (!match) return res.status(401).json({ error: 'Authentication required' })
const { payload } = await jwtVerify(match[1], key, {
algorithms: ['RS256'], issuer: JWT_ISSUER, audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'], maxTokenAge: '15m',
})
if (typeof payload.scope !== 'string' || !payload.scope.split(' ').includes('sftp:read')) {
return res.status(403).json({ error: 'Permission denied' })
}
} catch {
return res.status(401).json({ error: 'Invalid access token' })
}
next()
})
app.get('/files', (_req, res) => {
res.json(Array.from(catalog, ([id, item]) => ({ id, label: item.label })))
})
app.get('/files/:id', async (req, res) => {
const item = catalog.get(req.params.id)
if (!item) return res.status(404).json({ error: 'File not found' })
if (active >= 2) return res.status(503).json({ error: 'Gateway busy' })
active += 1
const abort = new AbortController()
const sftp = new SftpClient('gateway', {
error: () => abort.abort(),
end: () => abort.abort(),
close: () => abort.abort(),
})
const timer = setTimeout(() => abort.abort(), 30_000)
const disconnect = () => { if (!res.writableFinished) abort.abort() }
res.once('close', disconnect)
const socket = createConnection({ host: SFTP_HOST, port: sshPort })
socket.on('error', () => {})
const cancelSocket = () => socket.destroy()
abort.signal.addEventListener('abort', cancelSocket)
try {
await sftp.connect({
sock: socket, username: SFTP_USERNAME, privateKey: SFTP_PRIVATE_KEY,
hostHash: 'sha256', hostVerifier: (hash) => hash === SFTP_HOST_SHA256,
readyTimeout: 10_000,
})
if (await sftp.realPath(item.path) !== item.path) throw new Error('Unexpected path')
const info = await sftp.lstat(item.path)
if (!info.isFile || info.isSymbolicLink || !Number.isSafeInteger(info.size) ||
info.size < 0 || info.size > maxBytes) throw new Error('Invalid file')
abort.signal.throwIfAborted()
let received = 0
const limit = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
callback(received > info.size ? new Error('Byte limit exceeded') : null, chunk)
},
flush(callback) {
callback(received !== info.size ? new Error('Incomplete file') : null)
},
})
// With files this small, finish SFTP before committing an HTTP success response.
const chunks = []
const sink = new Writable({
write(chunk, _encoding, callback) { chunks.push(chunk); callback() },
})
await pipeline(sftp.createReadStream(item.path), limit, sink, { signal: abort.signal })
abort.signal.throwIfAborted()
res.set('Content-Type', 'application/octet-stream')
res.set('Content-Disposition', 'attachment; filename="' + req.params.id + '.bin"')
res.set('Content-Length', String(received))
await pipeline(Readable.from([Buffer.concat(chunks, received)]), res, { signal: abort.signal })
} catch {
console.error(JSON.stringify({ event: 'download_failed', requestId: res.locals.requestId }))
if (!res.headersSent && !res.destroyed) {
res.removeHeader('Content-Length')
res.removeHeader('Content-Disposition')
res.status(502).json({ error: 'Download failed', requestId: res.locals.requestId })
} else {
res.destroy()
}
} finally {
socket.destroy()
await sftp.end().catch(() => {})
clearTimeout(timer)
res.off('close', disconnect)
abort.signal.removeEventListener('abort', cancelSocket)
active -= 1
}
})
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).json({ error: 'Request failed' })
})
const port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid HTTP port')
const server = app.listen(port, '127.0.0.1', (error) => {
if (error) {
console.error('Gateway could not listen; check PORT and whether it is in use')
process.exitCode = 1
return
}
console.log('Open ' + APP_ORIGIN)
})
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Connect the browser
Save this as public/index.html, creating the public directory first. The token field is for
this local exercise; no shared SSH credential is embedded in the page.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>SFTP file browser</title>
</head>
<body>
<h1>SFTP files</h1>
<label>Your access token <input id="token" type="password" autocomplete="off" /></label>
<button id="list" type="button">List available files</button>
<ul id="files"></ul>
<p id="status" role="status"></p>
<script src="/files.js" defer></script>
</body>
</html>
Save this as public/files.js. The page disables its inputs during each request and retains at
most one download object URL. A failed catalog request clears the previous list.
const token = document.getElementById('token')
const list = document.getElementById('list')
const files = document.getElementById('files')
const status = document.getElementById('status')
let busy = false
let downloadUrl
function setBusy(value) {
busy = value
token.disabled = value
for (const button of document.querySelectorAll('button')) button.disabled = value
}
window.addEventListener('pagehide', () => {
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
})
async function api(path) {
const response = await fetch(path, {
headers: {
Authorization: 'Bearer ' + token.value,
'X-SFTP-Client': 'browser',
},
signal: AbortSignal.timeout(35_000),
})
if (!response.ok) throw new Error('Request rejected')
return response
}
list.addEventListener('click', async () => {
if (busy) return
setBusy(true)
status.textContent = 'Loading files…'
files.replaceChildren()
try {
const response = await api('/files')
const catalog = await response.json()
for (const item of catalog) {
const li = document.createElement('li')
const button = document.createElement('button')
button.type = 'button'
button.textContent = 'Download ' + item.label
button.addEventListener('click', async () => {
if (busy) return
setBusy(true)
status.textContent = 'Downloading…'
try {
const result = await api('/files/' + encodeURIComponent(item.id))
const blob = await result.blob()
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = item.id + '.bin'
link.click()
status.textContent = 'Download received.'
} catch {
status.textContent = 'Download failed.'
} finally {
setBusy(false)
}
})
li.append(button)
files.append(li)
}
status.textContent = 'Available files loaded.'
} catch {
status.textContent = 'Could not load files.'
} finally {
setBusy(false)
}
})
Supply a local SFTP server and token
Save this as sftp-server.Dockerfile. Package installation happens inside an owned container image,
and the launcher copies only this Dockerfile and the SSH configuration into its build context.
Keys and tokens are generated later and never enter the image.
FROM node:26.8.1-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && \
rm -rf /var/lib/apt/lists/* /etc/ssh/ssh_host_* && \
useradd --uid 10001 --home-dir /exports --shell /usr/sbin/nologin fixture && \
passwd -d fixture && mkdir -p /srv/sftp/exports && chmod 755 /srv/sftp
COPY sshd_config /etc/ssh/demo_config
CMD ["sh", "-c", "mkdir -p /run/sshd && cp /fixture/host-key /run/host-key && chmod 600 /run/host-key && cp /fixture/client-key.pub /run/authorized_keys && chmod 644 /run/authorized_keys && timeout --signal=TERM --kill-after=5 600 /usr/sbin/sshd -D -e -f /etc/ssh/demo_config"]
Save this as sshd_config. This replaces the demo container’s SSH configuration in full.
It does not include the distribution’s configuration files. The account is chrooted, and
internal-sftp -R refuses writes.
Port 22
ListenAddress 0.0.0.0
HostKey /run/host-key
PidFile /run/sshd.pid
AuthorizedKeysFile /run/authorized_keys
StrictModes yes
AllowUsers fixture
PermitRootLogin no
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
X11Forwarding no
PermitTTY no
PermitUserRC no
Subsystem sftp internal-sftp
Match User fixture
ChrootDirectory /srv/sftp
ForceCommand internal-sftp -R -d /exports
Save this as demo.ts. It creates fresh SSH keys, computes the trusted local host-key pin,
issues a five-minute RS256 token, and starts both services on available loopback ports.
The token stays in a private file. Two additional tokens exercise wrong scope and expiry.
This issuer demonstrates the JWT contract; it does not verify integration with a hosted identity provider.
import { type ChildProcess, execFile, spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { createConnection, createServer } from 'node:net'
import { join } from 'node:path'
import { setTimeout as pause } from 'node:timers/promises'
import { promisify } from 'node:util'
import { exportSPKI, generateKeyPair, SignJWT } from 'jose'
const root = import.meta.dirname
const state = join(root, '.demo')
const owner = randomUUID()
const name = 'sftp-browser-' + owner
const exec = promisify(execFile)
await mkdir(state, { mode: 0o700 }) // Refuse an existing directory; never clear someone else's state.
const environment = { PATH: process.env.PATH, DOCKER_HOST: 'unix:///var/run/docker.sock',
DOCKER_CONFIG: join(state, 'docker') }
async function command(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return exec(file, args, { cwd: root, env: environment, timeout: 240_000, maxBuffer: 8 * 1024 * 1024 })
}
const docker = (args: string[]) => command('docker', args)
let gateway: ChildProcess | undefined
let imageBuilt = false
let stopped = false
const stop = () => { stopped = true; gateway?.kill('SIGTERM') }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const lifetime = setTimeout(stop, 10 * 60_000)
try {
await writeFile(join(state, 'resources.json'), JSON.stringify({ owner, name }) + '\n', { mode: 0o600 })
for (const dir of ['docker', 'build', 'exports']) await mkdir(join(state, dir))
await copyFile(join(root, 'sftp-server.Dockerfile'), join(state, 'build', 'Dockerfile'))
await copyFile(join(root, 'sshd_config'), join(state, 'build', 'sshd_config'))
await docker(['build', '--tag', name, join(state, 'build')])
imageBuilt = true
if (stopped) throw new Error('Demo was stopped during startup')
for (const key of ['host-key', 'client-key']) {
await command('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(state, key)])
}
if (stopped) throw new Error('Demo was stopped during startup')
// A deterministic binary sample, including NUL bytes and content beyond the first read.
const sample = Buffer.from(Array.from({ length: 65_568 }, (_, i) => i % 251))
await writeFile(join(state, 'exports', 'report.bin'), sample, { mode: 0o644 })
await chmod(join(state, 'exports'), 0o755)
const publicHost = (await readFile(join(state, 'host-key.pub'), 'utf8')).split(' ')[1]
if (!publicHost) throw new Error('Missing host public key')
const pin = createHash('sha256').update(Buffer.from(publicHost, 'base64')).digest('hex')
await docker(['run', '--detach', '--rm', '--name', name, '--label', 'sftp.demo.owner=' + owner,
'--read-only', '--tmpfs', '/run', '-p', '127.0.0.1::22',
'--mount', `type=bind,src=${state},dst=/fixture,readonly`,
'--mount', `type=bind,src=${join(state, 'exports')},dst=/srv/sftp/exports,readonly`, name])
const sshPort = Number((await docker(['port', name, '22/tcp'])).stdout.trim().split(':').at(-1))
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) throw new Error('Invalid published SSH port')
let ready = false
for (let attempt = 0; attempt < 40 && !stopped; attempt++) {
ready = await new Promise<boolean>((resolve) => {
const socket = createConnection({ host: '127.0.0.1', port: sshPort })
socket.setTimeout(500)
const done = (ok: boolean) => { socket.destroy(); resolve(ok) }
socket.once('data', (data) => done(data.toString().startsWith('SSH-2.0-OpenSSH_')))
socket.once('error', () => done(false))
socket.once('timeout', () => done(false))
})
if (ready) break
await pause(100)
}
if (!ready || stopped) throw new Error('Demo SSH server did not become ready')
const probe = createServer()
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve))
const address = probe.address()
if (!address || typeof address === 'string') throw new Error('Missing HTTP port')
const port = address.port
await new Promise<void>((resolve) => probe.close(() => resolve()))
const { publicKey, privateKey } = await generateKeyPair('RS256')
const now = Math.floor(Date.now() / 1000)
for (const [file, scope, expiry] of [
['token.txt', 'sftp:read', now + 300],
['wrong-scope.txt', 'sftp:upload', now + 300],
['expired.txt', 'sftp:read', now - 60],
] satisfies [string, string, number][]) {
const access = await new SignJWT({ scope }).setProtectedHeader({ alg: 'RS256' })
.setSubject('local-reader').setIssuer('urn:sftp-browser-demo').setAudience('sftp-browser')
.setIssuedAt(now).setExpirationTime(expiry).sign(privateKey)
await writeFile(join(state, file), access + '\n', { mode: 0o600 })
}
const jwtPublicKey = await exportSPKI(publicKey)
await writeFile(join(state, 'jwt-public.pem'), jwtPublicKey, { mode: 0o600 })
console.log('Copy the token from ' + join(state, 'token.txt'))
console.log('Sample SHA-256: ' + createHash('sha256').update(sample).digest('hex'))
if (stopped) throw new Error('Demo was stopped before gateway startup')
gateway = spawn(process.execPath, [join(root, 'index.mjs')], { stdio: 'inherit', env: {
PATH: process.env.PATH, APP_ORIGIN: 'http://127.0.0.1:' + port, PORT: String(port),
JWT_PUBLIC_KEY: jwtPublicKey, JWT_ISSUER: 'urn:sftp-browser-demo',
JWT_AUDIENCE: 'sftp-browser', SFTP_HOST: '127.0.0.1', SFTP_PORT: String(sshPort),
SFTP_USERNAME: 'fixture', SFTP_PRIVATE_KEY: await readFile(join(state, 'client-key'), 'utf8'),
SFTP_HOST_SHA256: pin,
} })
const child = gateway
const exit = await new Promise<number | null>((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code) => resolve(code))
})
if (!stopped && exit !== 0) throw new Error('Gateway exited unsuccessfully')
} finally {
clearTimeout(lifetime)
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
gateway?.kill('SIGTERM')
// The UUID and label restrict cleanup to the container this invocation created.
const existing = await docker(['inspect', '--format', '{{ index .Config.Labels "sftp.demo.owner" }}', name]).catch(() => null)
if (existing?.stdout.trim() === owner) await docker(['rm', '--force', name])
if (imageBuilt) await docker(['image', 'rm', name])
await rm(state, { recursive: true })
}
From the parent directory, run the launcher in the foreground:
node sftp-browser-demo/demo.ts
Open the URL printed by the gateway. Copy the token from sftp-browser-demo/.demo/token.txt
into Your access token, then click
List available files and
Download Sample file.
Download received. means the browser received the complete HTTP
body and initiated its download. Check your browser’s downloads for the saved file; the page cannot
confirm that you kept it on disk.
The sample is 65,568 binary bytes. Compare the SHA-256 of the saved report.bin with the
Sample SHA-256: printed by the launcher. Save this as verify-download.ts inside the project:
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
const path = process.argv[2]
const expected = process.argv[3]
if (!path || !/^[a-f0-9]{64}$/.test(expected ?? '')) throw new Error('Supply a file and SHA-256')
const actual = createHash('sha256').update(await readFile(path)).digest('hex')
if (actual !== expected) throw new Error('Downloaded bytes do not match')
console.log('Downloaded bytes match')
Pass your actual download path and the printed digest as arguments:
node sftp-browser-demo/verify-download.ts "$HOME/Downloads/report.bin" YOUR_PRINTED_SHA256
A 401 means authentication was rejected, including an expired token; 403 means the request
origin, client header, query, or scope was refused. A 502 means the gateway could not confirm
the SFTP read, for example because of a host-key mismatch, symlink, oversized file, or lost connection.
Try the tokens from .demo/wrong-scope.txt and .demo/expired.txt: both catalog requests must fail.
After token expiry, stop and restart the demo to get a new token.
Press Ctrl+C in the launcher terminal to stop the gateway and remove its container, image tag,
keys, and token. The demo also stops after ten minutes. The project and your saved download remain.
During startup, cleanup waits for the current build or Docker command to finish; each command has
a four-minute timeout. Stop the demo after startup for the normal immediate shutdown path.
An interrupted cleanup may leave .demo behind. Inspect .demo/resources.json and verify the
container’s sftp.demo.owner label before manually removing that container and its image tag.
Then remove .demo; the launcher refuses to overwrite it on the next run.
Adapt the gateway to your SFTP account
Supply the same server-only environment variables used by demo.ts, with your verified issuer,
audience, and public key. Set SFTP_HOST_SHA256 to the administrator-verified, 64-character
lowercase hexadecimal SHA-256 of the raw SSH host public key, as required by
ssh2’s host verifier.
An OpenSSH SHA256:base64 fingerprint uses a different encoding and cannot be pasted into this field.
Only a trusted publisher may modify the catalog’s files; publish immutable files atomically.
Canonical-path and lstat() checks cannot stop a malicious writer from racing the later open,
and a byte count cannot detect same-length content replacement. Every valid sftp:read token can
read the entire configured catalog. Multiple tenants need separate catalogs and SSH accounts derived
from verified server-side authorization records.
Use HTTPS at the browser edge and keep SSH host-key verification. The browser requests a same-origin API with a custom header, and the gateway enables no CORS access. Origin checks supplement token authorization; they do not authenticate non-browser callers. The two-download limit applies to one Node process, and buffering requires memory for those files plus copies and protocol overhead. Deployment-wide quotas and content scanning are separate decisions.
Choose a browser-side SSH alternative
If users need their own SSH session, hullarb/ssheasy
builds a Go/WebAssembly client together with a WebSocket-to-TCP proxy and frontend.
c2FmZQ/sshterm is a complete Go/WASM application with SFTP
uploads and downloads, SSH key management, and a tlsproxy WebSocket endpoint.
Follow each application’s deployment and host-verification contract. These are architectural
references, not libraries installed or exercised by this example. There is no benchmark here:
measure startup cost and transfer latency for your own workload.
The historical sftp-ws project carries SFTP v3 over WebSockets instead of SSH, and its README warns that it is not sufficiently tested. It is not a drop-in transport proxy for an existing SSH server. The authenticated HTTP gateway above is the working path in this walkthrough.
For SFTP imports into Transloadit Assemblies, see the 🤖 /sftp/import documentation. For browser uploads, see Uppy.
