Issuing Short-Lived Upload Tokens with JWTs
When a user starts an upload, have your main API mint a JWT that authorises exactly that upload — aud: "upload-service", a jti equal to the upload ID, claims for the target prefix, maximum size and allowed types, and an exp a few minutes away — signed with an Ed25519 key whose public half the upload service (or edge worker) uses to verify it without calling back; the upload service rejects tokens that are expired, for a different audience, over their limits, or already used.
Upload endpoints often live apart from the main application: a dedicated upload service, a tus server, an edge worker in front of storage. They need to know who may upload what without sharing the main app’s session store or calling it on every chunk. A capability token solves this: the main API decides once, encodes the decision in a signed token, and the upload service enforces it locally. Done carelessly, it becomes a long-lived bearer credential with vague scope. This page belongs to upload authorization and tenant isolation in backend validation and cloud storage architecture. It complements storage-level scoping in scoping upload keys per user with IAM policy variables.
When to use this approach
- Uploads are handled by a separate service (tus server, upload gateway, Cloudflare Worker) that should not depend on your main session store.
- You need authorisation that survives long uploads across many requests without re-checking the session each time.
- You want to encode per-upload limits — size, type, destination — that the upload service enforces.
Prerequisites
jose5.x in both the issuing API and the verifying service (works in Node, Deno, Bun and Workers).- An Ed25519 key pair; the private key only in the issuer, the public key (or a JWKS endpoint) available to verifiers.
- A small store for used token IDs (Redis, KV, or the upload records table) to prevent replay.
- HTTPS everywhere — bearer tokens are only as safe as the channel.
What the token carries
Implementation
Issuer (main API):
import { SignJWT, importPKCS8 } from "jose";
import { randomUUID } from "node:crypto";
const PRIVATE_KEY = await importPKCS8(process.env.UPLOAD_TOKEN_KEY_PEM!, "EdDSA");
const KID = "upl-2026-09";
export interface UploadGrant { token: string; uploadId: string; expiresAt: number }
export async function issueUploadToken(
user: { id: string; tenantId: string },
req: { size: number; type: string },
limits = { maxBytes: 500 * 1024 * 1024, types: ["video/mp4", "video/quicktime", "image/jpeg", "image/png"] },
): Promise<UploadGrant> {
if (req.size <= 0 || req.size > limits.maxBytes) throw new Error("file too large");
if (!limits.types.includes(req.type)) throw new Error("type not allowed");
const uploadId = randomUUID();
const ttl = 300;
const token = await new SignJWT({
tid: user.tenantId,
pfx: `${user.tenantId}/${user.id}/${uploadId}/`,
max: req.size, // the declared size, not the global limit
typ: req.type,
})
.setProtectedHeader({ alg: "EdDSA", kid: KID, typ: "upload+jwt" })
.setIssuer("https://app.example.com")
.setAudience("upload-service")
.setSubject(user.id)
.setJti(uploadId)
.setIssuedAt()
.setExpirationTime(`${ttl}s`)
.sign(PRIVATE_KEY);
return { token, uploadId, expiresAt: Date.now() + ttl * 1000 };
}
Verifier (upload service or edge worker):
import { jwtVerify, createRemoteJWKSet, errors } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://app.example.com/.well-known/upload-jwks.json"));
interface UploadClaims { sub: string; jti: string; tid: string; pfx: string; max: number; typ: string }
export class TokenError extends Error { constructor(msg: string, readonly status: number) { super(msg); } }
export async function authoriseUpload(
authHeader: string | null,
req: { key: string; contentType: string; contentLength: number },
markUsed: (jti: string, until: number) => Promise<boolean>, // true if newly marked
): Promise<UploadClaims> {
const token = authHeader?.replace(/^Bearer /, "");
if (!token) throw new TokenError("missing token", 401);
let payload;
try {
({ payload } = await jwtVerify(token, JWKS, {
issuer: "https://app.example.com",
audience: "upload-service",
algorithms: ["EdDSA"], // never accept "none" or HS256 with a public key
typ: "upload+jwt",
clockTolerance: 30, // seconds of skew between services
maxTokenAge: "10m",
}));
} catch (err) {
if (err instanceof errors.JWTExpired) throw new TokenError("token expired — request a new one", 401);
throw new TokenError("invalid token", 401);
}
const c = payload as unknown as UploadClaims;
if (!req.key.startsWith(c.pfx) || req.key.includes("..")) throw new TokenError("key outside granted prefix", 403);
if (req.contentLength > c.max) throw new TokenError("larger than granted", 413);
if (req.contentType !== c.typ) throw new TokenError("type differs from grant", 415);
// Single use: the first request with this jti wins until the token would have expired anyway.
if (!(await markUsed(c.jti, (payload.exp ?? 0) * 1000))) throw new TokenError("token already used", 409);
return c;
}
Line-by-line on the decisions that matter
- EdDSA (Ed25519) instead of HS256. With a shared HMAC secret, every verifier can also mint tokens; a compromised upload worker becomes an issuer. With an asymmetric key, verifiers hold only the public key.
algorithms: ["EdDSA"]andtyp. Pinning the algorithm blocks algorithm-confusion attacks, and checkingtyp: upload+jwtstops the service accepting some other token your issuer signs (a session token, say) that happens to carry a matching audience.aud: "upload-service". Tokens for the upload service are useless at other services and vice versa. Every verifier must check the audience; a token without it is a general-purpose credential.jtiequals the upload ID, and it is single-use. Marking thejtias used on first acceptance prevents replay: a token captured from a log or a proxy cannot start a second upload. For chunked protocols, mark it used when the upload session is created, and authorise later chunks by the session, not the token.maxis the declared size. Granting the global limit would let a client that declared 2 MB upload 500 MB with the same token. EnforceContent-Length(and, in streaming handlers, the running byte count) against it.- Five-minute expiry. The token only needs to live until the upload starts. Once an upload session exists, its own identity carries the rest. Short expiries make leaked tokens nearly worthless.
Token lifetime versus upload lifetime
Key rotation without downtime
Upload tokens are verified by services you do not want to redeploy for every key change, which is why the verifier fetches a JWKS rather than embedding the key. Rotation is then a publishing exercise. Generate the new key pair and add its public key to the JWKS with a new kid, alongside the current one. Wait for verifiers’ JWKS caches to refresh — jose’s remote key set caches for a few minutes and refetches when it sees an unknown kid. Switch the issuer to sign with the new key. After the longest token lifetime has passed (minutes, for upload tokens), remove the old public key from the JWKS.
Because upload tokens live for minutes, rotation windows are short and an emergency rotation after a key leak is fast: publish a new key, switch the issuer, and remove the leaked key immediately; the cost is that tokens issued in the last few minutes fail and clients request new ones. Keep the private key in a secrets manager or KMS-backed signer, never in environment files committed to repositories.
Configuration gotchas
JWTClaimValidationFailed: "aud" claim check failed. The issuer set a different audience string than the verifier expects — often a URL in one and a name in the other. Agree on one constant and share it.
JWSSignatureVerificationFailed after deploying a new issuer. The verifier has a cached JWKS without the new kid. Publish keys before signing with them, and make sure the JWKS endpoint is cacheable but short-lived (Cache-Control: max-age=300).
JWTExpired for users on slow devices. The token expired between issue and first use because the client did slow preprocessing (resizing, hashing) first. Issue the token after preprocessing, or refresh it transparently when the first upload request returns 401 with an expiry reason.
Tokens appearing in logs. Bearer tokens in query strings end up in access logs and CDN logs. Send them in the Authorization header only, and redact that header in every log pipeline.
Verification
# Mint a token (dev helper), then use it once, twice, and after expiry.
TOKEN=$(node scripts/mint-upload-token.mjs --user u-8f3a2c --size 48231 --type image/jpeg)
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://uploads.example.com/files \
-H "Authorization: Bearer $TOKEN" -H 'Upload-Length: 48231' -H 'Content-Type: image/jpeg' # 201
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://uploads.example.com/files \
-H "Authorization: Bearer $TOKEN" -H 'Upload-Length: 48231' -H 'Content-Type: image/jpeg' # 409 reused
# Declared bigger than granted: refused.
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://uploads.example.com/files \
-H "Authorization: Bearer $(node scripts/mint-upload-token.mjs --size 1000)" -H 'Upload-Length: 999999' # 413
Frequently Asked Questions
Why not just use the user’s session cookie at the upload service?
It couples the upload service to the session store and gives it every permission the session has. A purpose-built token carries only the permission to upload one file, and the upload service can verify it offline.
Should I use opaque tokens instead of JWTs?
Opaque tokens looked up in a shared store are simpler to revoke and leak nothing if logged. JWTs avoid the lookup and work at edges without store access. For uploads, where tokens live minutes and are single-use, either works; JWTs fit better when the verifier is an edge worker.
How does this relate to presigned URLs?
A presigned URL is itself a short-lived, narrowly scoped capability — for storage directly. Use presigned URLs when the browser talks to S3, GCS or Azure; use upload tokens when it talks to your own upload service, which may then write to storage with its own credentials.