Bridge SFTP to browsers with WebAssembly and Websockets
Browsers can work with SFTP through a transport bridge or a server API. The distinction matters: WebAssembly can run SSH code, but it does not give a standard web page native TCP sockets. This article compares those architectures and implements a small, authenticated file browser.
Introduction: the browser-sftp challenge
SFTP normally runs inside SSH over TCP. A browser’s WebSocket connection begins with an HTTP upgrade and carries WebSocket frames; pointing it at port 22 does not turn it into an SSH socket. A server must translate the transport or perform the SFTP operations on the browser’s behalf.
Understanding the security model
With browser-side SSH, the SSH session ends in the browser. The transport proxy handles network reachability, while the browser SSH implementation authenticates the remote host and user. With an HTTP API gateway, SSH ends on the gateway, which keeps SSH credentials server-side and authorizes each application operation.
Neither design permits an unrestricted destination proxy. Authenticate access, restrict destinations, check the browser Origin, and bound connection lifetimes and traffic. WSS/HTTPS protects the browser-to-gateway hop; SSH host-key verification separately authenticates the SFTP server.
Solution 1: webassembly-powered SFTP clients
hullarb/ssheasy is a Go/WASM application with its own
WebSocket-to-TCP proxy and frontend deployment. It is not an npm module exporting a drop-in
SSHClient. Its build compiles both browser and proxy components.
c2FmZQ/sshterm is also a complete Go/WASM application.
Its documented features include SFTP uploads and downloads, an SSH agent, and key management.
It uses a tlsproxy WebSocket endpoint to reach SSH servers. Follow that application’s
deployment and proxy contracts rather than assuming a generic WebSocket endpoint is compatible.
These applications are architectural references here, not dependencies of the runnable example. Review their current releases before adopting them. A user’s own SSH keys can be appropriate for a browser SSH client, with a deliberate storage and recovery policy; shared server credentials must never be bundled into the page. Protect host-key verification and the frontend supply chain.
Solution 2: websocket proxy approach with sftp-ws
The historical sftp-ws package implements SFTP v3
over WebSockets instead of SSH. Its npm 0.8.0 package depends on ws ~0.8.0.
It serves a filesystem interface; it does not automatically connect that interface to a remote
SSH server. Its repository describes the SSH bridge as future work.
Example: setting up an sftp-ws server (conceptual)
The historical server constructor takes options such as port, virtualRoot, and
readOnly. Version 0.8.0 delegates connection verification to verifyClient(info, accept);
the old example’s credentials callback was not that authentication contract. Its accept()
implementation also expects the old WebSocket library’s upgradeReq field. A writable local
directory behind that snippet should not be exposed as an upload service.
This article keeps the historical section for context but uses the complete, bounded HTTP bridge below for its working example. It supplies a fixed catalog and authorized downloads rather than granting browser clients a general remote filesystem interface.
Example: sftp-ws client in the browser (conceptual)
The published client’s connect(url, options, callback) expects a URL.
It is not the earlier conceptual connect(webSocket, credentials, callback) interface, and
Node’s require() is not a browser import mechanism.
For the working API bridge, save this page as public/index.html. Each user obtains a
short-lived access token through your existing identity provider. The manual entry field is for
testing; it never contains an embedded shared secret.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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 the following as public/files.js. Files are bounded to 5 MiB by the server, so the
download Blob also has a known maximum size.
const token = document.getElementById('token')
const list = document.getElementById('list')
const files = document.getElementById('files')
const status = document.getElementById('status')
let busy = false
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
busy = true
list.disabled = true
try {
const response = await api('/files')
const catalog = await response.json()
files.replaceChildren()
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
busy = true
button.disabled = true
try {
const result = await api('/files/' + encodeURIComponent(item.id))
const blob = await result.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = item.id + '.bin'
link.click()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
status.textContent = 'Download received.'
} catch {
status.textContent = 'Download failed.'
} finally {
button.disabled = false
busy = false
}
})
li.append(button)
files.append(li)
}
status.textContent = 'Available files loaded.'
} catch {
status.textContent = 'Could not load files.'
} finally {
busy = false
list.disabled = false
}
})
Solution 3: building a secure Node.js API bridge with ssh2-sftp-client
The server maps a public file ID to a fixed path. It does not accept directory or hostname
parameters, and it does not fetch an unbounded SFTP directory listing.
A sftp:read scope grants access to this entire configured catalog. For multiple tenants,
derive separate catalogs and SSH accounts from verified server-side authorization records.
Example: Node.js API with ssh2-sftp-client
Use Node.js 24. Create a project with "type": "module" in package.json:
yarn init -2
yarn add express@5.2.1 helmet@8.3.0 jose@6.2.12 ssh2-sftp-client@12.1.1
mkdir public
Save this as index.js:
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
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 catalog = new Map([
['report', { label: 'Monthly report', path: '/exports/report.pdf' }],
])
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 < 1 || 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)
},
})
res.set('Content-Type', 'application/octet-stream')
res.set('Content-Disposition', 'attachment; filename="' + req.params.id + '.bin"')
res.set('Content-Length', String(info.size))
await pipeline(sftp.createReadStream(item.path), limit, 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 server = app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1')
server.requestTimeout = 35_000
server.headersTimeout = 10_000
Set APP_ORIGIN, JWT_PUBLIC_KEY, JWT_ISSUER, and JWT_AUDIENCE for your
application and issuer. Set SFTP_HOST, SFTP_PORT, SFTP_USERNAME, and
SFTP_PRIVATE_KEY in server-only configuration. PEM values contain actual newlines.
Set SFTP_HOST_SHA256 to the administrator-verified, 64-character lowercase hexadecimal
SHA-256 digest of the raw SSH host public key, as required by
ssh2’s host verifier.
Do not paste an OpenSSH SHA256:base64 fingerprint into this hexadecimal field.
Provision a read-only, chrooted SFTP account and a real /exports/report.pdf. Only a trusted
publisher may modify this directory; publish immutable files atomically. Canonical-path and
lstat() checks alone cannot prevent a malicious writer from racing the later open.
Run yarn node index.js, visit the page at its configured origin, list the catalog, and download
the report. Test wrong scope, expired tokens, cross-origin requests, unknown IDs, traversal,
symlinks, host-key mismatch, oversized or changing files, and disconnects against a disposable
SFTP server. The pinned library
provides the stream and file-stat APIs used here.
Security considerations and best practices
Keep HTTPS at the browser edge, verify SSH host keys, and never let request data choose a destination. Limit authenticated users to server-owned catalogs. Apply per-user rate limits and global quotas at the deployment boundary; the two-request limit here applies to one Node process.
Downloads stream with backpressure and a strict byte count. A failed transfer may have already sent a partial body; its connection is closed instead of appending a JSON error to file bytes. The declared length lets the browser detect truncation. File signatures and virus scanning remain separate content-policy decisions.
Performance comparison and choosing the right approach
A WASM client runs SSH and cryptography in the browser and pays a startup download/compile cost. A WebSocket transport bridge carries that session through a server. SFTP-over-WebSocket is a different protocol arrangement and does not imply an SSH session.
An HTTP API bridge centralizes SSH and application authorization. The example serves a small fixed catalog and bounded files; it is not a benchmark or a general-purpose SFTP file manager. Measure real workload latency, concurrency, and memory before choosing an architecture.
Conclusion
WebAssembly can host an SSH implementation, WebSockets can carry a browser transport, and a Node.js API can offer restricted SFTP operations. Choose where SSH terminates and enforce authentication at each boundary.
Transloadit’s 🤖 /sftp/import Robot imports files from SFTP into your Assemblies. For browser uploading, see Uppy.
