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

  1. jose 5.x in both the issuing API and the verifying service (works in Node, Deno, Bun and Workers).
  2. An Ed25519 key pair; the private key only in the issuer, the public key (or a JWKS endpoint) available to verifiers.
  3. A small store for used token IDs (Redis, KV, or the upload records table) to prevent replay.
  4. HTTPS everywhere — bearer tokens are only as safe as the channel.

What the token carries

Claims in an upload capability token The token header names the EdDSA algorithm and key ID. The payload has issuer, audience upload-service, subject user ID, jti equal to the upload ID, issued-at and expiry five minutes later, and custom claims for the key prefix, maximum bytes, allowed MIME types and tenant. The signature covers header and payload. A token that means exactly one upload header alg: EdDSA · kid: upl-2026-09 payload iss: app · aud: upload-service sub: u-8f3a2c · tid: t-acme jti: 3f0a2b6c (the upload ID) iat / exp: +300 s pfx: t-acme/u-8f3a2c/3f0a2b6c/ max: 524288000 · types: video/* which key verifies it only the upload service accepts it who, in which tenant single use: stored when first seen minutes, not days where bytes may go how much and what Everything the upload service needs to decide is in the token; nothing requires a call back to the app.
Narrow claims turn a bearer token into a capability that cannot be reused for anything else.

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"] and typ. Pinning the algorithm blocks algorithm-confusion attacks, and checking typ: upload+jwt stops 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.
  • jti equals the upload ID, and it is single-use. Marking the jti as 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.
  • max is the declared size. Granting the global limit would let a client that declared 2 MB upload 500 MB with the same token. Enforce Content-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

Token lifetime ends when the upload session begins The token is issued at zero and expires at five minutes. The client creates an upload session with it at eight seconds; the token's jti is marked used. The upload itself continues for forty minutes, authorised by the session ID, not by the token, which has long expired. The token opens the door; the session keeps it open token valid 5 min, used at 8 s upload session chunks authorised by session ID for 40 min 0 5 min 40 min A token valid for the whole upload would need a 40-minute lifetime and could start other uploads meanwhile. Exchange it once for a session bound to one upload, and let it expire.
Separating "may start this upload" from "is continuing this upload" keeps tokens short-lived without breaking long transfers.

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.

Four-step key rotation through a JWKS Step one publishes the new public key alongside the old one. Step two waits for verifier caches to refresh. Step three switches signing to the new key. Step four removes the old public key after the maximum token lifetime. Publish, wait, switch, retire 1. publish JWKS: old + new sign with old 2. wait verifier caches pick up new kid 3. switch sign with new old still verifies 4. retire after max token age: drop old key With five-minute tokens the whole rotation fits in a quarter of an hour, with no verifier redeploys.
Short token lifetimes make key rotation, planned or emergency, quick and routine.

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.