Skip to main content

Custom-Domain Signed File Links

This guide applies to Cloud Storage (PG Storage). Shared files are objects in storage buckets, such as images, documents, or archives, not table data in the PostgreSQL database.

Provide signed, time-limited file download links through a custom domain. The short link verifies the signature and expiration, then redirects to a temporary platform-signed URL. File content does not pass through your business cloud function.

1. Goals and Constraints​

Expected behavior

RequirementDescription
Custom domainThe sharing URL is https://dl.example.com/s/xxxx
SignatureValidate file and expiration parameters and reject tampered tokens
ExpirationReject expired short links without caching their redirects
No file proxying through the business serviceThe platform storage path returns file content; the function does not forward it

Endpoint restriction

Do not replace the domain in a platform-signed URL with a custom domain. In the original example's test, the same valid token returned 200 on the platform gateway domain but 401 STORAGE_INVALID_SIGNATURE after replacing it with a custom domain. Use the complete signed URL returned by the platform.

This design signs a separate short link and, after verification, returns a 302 redirect to a newly generated temporary platform URL.

Sharing Links Are Access Credentials

Anyone holding a valid short link can access the file. Signatures prevent tampering, not forwarding of valid links, and cannot revoke files already downloaded.

2. Architecture​

Plain-text version

1. Issue: Business server → POST /s/_sign → Function → HMAC → Custom-domain short link

2. Access: Browser → GET /s/<token> → Function
├─ Invalid or expired → 403
└─ Valid → createSignedUrl(60s)
└─ 302 Location → Browser

3. Download: Browser → Platform-signed URL → Platform gateway → File stream

File content does not pass through the signed-link function. The function verifies the signature and expiration and generates the redirect URL.

3. Core Design​

3.1 Token Structure​

<base64url(payload)>.<base64url(HMAC-SHA256(base64url(payload)))>
{ "b": "bucket1", "k": "docs/report.pdf", "e": 1793779491, "j": "xQ9mT2pLk" }
FieldMeaning
bStorage bucket
kObject path within the bucket
eExpiration time in Unix seconds
jRandom ID for troubleshooting and future revocation support

The payload uses Base64URL encoding and can be decoded. It does not hide file paths or other sensitive information. The signature uses HMAC-SHA256:

  • Keep the signing secret only in server environment variables; clients do not hold it.
  • Sign the encoded payload. Changes to the file path or expiration invalidate the original signature.
  • Verification does not require a database. Persist revocation state separately if individual links must be revoked.

3.2 Two Expiration Periods​

TypeDurationControlsConfiguration
Short-link tokenDefaults to 1 hour, maximum 7 daysWhether the sharing link can obtain a temporary URLttl when issuing the link
Platform-signed URLDefaults to 60 secondsWhether new requests can access the redirect destinationREDIRECT_MAX_AGE

The platform-signed URL appears in the browser's address bar. A 60-second expiration limits its reuse window.

The two expiration periods are calculated independently. A platform-signed URL obtained before the short link expires can remain valid until its own expiration, even after the short link expires. Expiration does not revoke completed downloads.

3.3 Use 302 and Disable Redirect Caching​

A 301 is a permanent redirect. Browsers may cache its destination and bypass short-link verification on subsequent visits. Reusing the old temporary URL can make access behavior inconsistent with the short link's expiration.

Use 302 with:

Cache-Control: no-store, no-cache, must-revalidate, max-age=0

If CDN or gateway caching is enabled, configure the /s route not to cache. Cache settings on the short-link response do not configure caching for the destination file response.

3.4 Return a Redirect Object from the Function​

The function returns the following object, which the gateway converts to an HTTP response. The browser follows the Location URL after receiving the 302 response:

return {
statusCode: 302,
headers: {
location: signedUrl,
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'x-robots-tag': 'noindex, nofollow',
},
body: '',
};

4. Storage APIs in PostgreSQL Environments​

4.1 Storage API Usage​

Call storage operations through app.storage.from(bucket), passing the bucket name and object path separately:

const { data, error } = await app.storage.from('bucket1').createSignedUrl('docs/a.pdf', 60);
if (error || !data?.fullSignedURL) {
throw new Error(error?.message || 'Unable to create signed URL');
}
const signedUrl = data.fullSignedURL;

Common methods:

MethodPurpose
createSignedUrl(path, expiresIn)Creates a time-limited URL; expiresIn is in seconds
upload(path, body)Uploads an object
download(path)Downloads an object
list(prefix)Lists objects
getPublicUrl(path)Returns a public-bucket URL; not applicable to private buckets

The result is { data, error }. Check error and the actual returned fields.

Pass the bucket name to from(bucket). The path in createSignedUrl excludes the bucket name. The short-link API uses a combined path such as bucket1/docs/a.pdf; do not mix these formats.

See Signed Download URLs and Storage SDK API.

4.2 Access Credentials​

ApproachDescription
Server API keyUses the service_role role and is passed as accessKey during SDK initialization; server-side only
Temporary credentials and RLSUses runtime-injected function credentials with the required Row Level Security (RLS) policies

This guide uses a server API key. See Server API Keys for creation instructions. Store the key only in server environment variables, never send it to the browser.

5. Implementation​

The examples below show the core logic. Complete request parsing, path validation, and error handling before deployment. The signing endpoint verifies its caller with SIGN_ADMIN_KEY; the business server must check file access permissions first.

5.1 Initialize the SDK​

Use @cloudbase/js-sdk v3 with PostgreSQL storage API support:

const cloudbase = require('@cloudbase/js-sdk');
const crypto = require('node:crypto');

const app = cloudbase.init({
env: process.env.TCB_ENV,
accessKey: process.env.CB_API_KEY,
});

If CB_API_KEY is not configured, use the temporary credentials injected by the function runtime:

cloudbase.init({
env: process.env.TCB_ENV,
auth: {
secretId: process.env.TENCENTCLOUD_SECRETID,
secretKey: process.env.TENCENTCLOUD_SECRETKEY,
sessionToken: process.env.TENCENTCLOUD_SESSIONTOKEN,
},
});

Temporary credentials require the relevant permissions and are not equivalent to a server API key.

5.2 Issue Tokens​

function signToken(payload, secret) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', secret).update(body).digest('base64url');
return `${body}.${sig}`;
}

const payload = {
b: 'bucket1',
k: 'docs/report.pdf',
e: Math.floor(Date.now() / 1000) + 3600,
j: crypto.randomBytes(9).toString('base64url'),
};
const url = `https://dl.example.com/s/${signToken(payload, process.env.SIGN_SECRET)}`;

5.3 Verify and Redirect​

function verifyToken(token, secret) {
if (!secret || typeof token !== 'string' || token.length > 2048) return null;
const parts = token.split('.');
if (parts.length !== 2) return null;
const [body, sig] = parts;
if (!body || !sig || !/^[A-Za-z0-9_-]+$/.test(body)) return null;

const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url');
const received = Buffer.from(sig, 'utf8');
const expectedBytes = Buffer.from(expected, 'utf8');
if (received.length !== expectedBytes.length) return null;
if (!crypto.timingSafeEqual(received, expectedBytes)) return null;

try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (!payload || typeof payload.b !== 'string' || typeof payload.k !== 'string') return null;
if (!Number.isInteger(payload.e) || payload.e <= Math.floor(Date.now() / 1000)) return null;
return payload;
} catch {
return null;
}
}

async function handle(token) {
const meta = verifyToken(token, process.env.SIGN_SECRET);
if (!meta) return { statusCode: 403, body: 'link_invalid_or_expired' };

try {
const maxAge = Number(process.env.REDIRECT_MAX_AGE || 60);
const { data, error } = await app.storage.from(meta.b).createSignedUrl(meta.k, maxAge);
if (error || !data?.fullSignedURL) {
return { statusCode: 404, body: 'not_found' };
}
return {
statusCode: 302,
headers: {
location: data.fullSignedURL,
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'x-robots-tag': 'noindex, nofollow',
},
body: '',
};
} catch {
return { statusCode: 502, body: 'sign_failed' };
}
}

Signature verification and expiration checks are independent. A valid signature proves that a server holding the secret issued the token, not that the token is still valid.

5.4 Register the Route​

Add a /s prefix route on the custom domain, pointing to the signed-link function, and enable Path Passthrough. Leave the existing storage route unchanged.

{
"Path": "/s",
"UpstreamResourceType": "SCF",
"UpstreamResourceName": "signed-link",
"EnableAuth": false,
"EnablePathTransmission": true
}

This is a route field example. Complete configuration depends on the target domain and environment. See Custom Domain Configuration.

EnableAuth: false disables gateway login authentication, not all authentication: downloads verify HMAC and expiration, while the signing endpoint verifies x-sign-key.

5.5 Function Environment Variables​

The function uses 8 environment variables: 3 required, 1 recommended, and 4 optional.

Required

VariablePurposeFormat or SourceIf Missing
SIGN_SECRETHMAC secret shared by signing and verificationBase64URL encoding of 32 random bytesSigning endpoint returns 500 server_not_configured
SIGN_ADMIN_KEYCredential for /s/_sign, compared against x-sign-keyBase64URL encoding of 24 random bytesSigning endpoint returns 500 server_not_configured
TCB_ENVEnvironment ID for the storage SDKTarget environment IDStorage initialization cannot work correctly

Recommended

VariablePurposeFormat or SourceIf Missing
CB_API_KEYservice_role server API key for createSignedUrlCreate and inject into function environment variablesFalls back to temporary credentials; insufficient permissions prevent obtaining download URLs

Optional

VariableDefaultPurpose
REDIRECT_MAX_AGE60Platform-signed URL validity in seconds
DEFAULT_TTL_SECONDS3600Short-link default validity when ttl is omitted
MAX_TTL_SECONDS604800Maximum short-link validity
DEFAULT_BUCKETEmptyDefault bucket when none is explicitly provided

Injected by the runtime; no manual configuration

VariablePurpose
TENCENTCLOUD_SECRETID, TENCENTCLOUD_SECRETKEY, TENCENTCLOUD_SESSIONTOKENFunction temporary credentials, used only when CB_API_KEY is absent; all three are required

Secret boundaries

ComparisonSIGN_SECRETSIGN_ADMIN_KEY
PurposeSigns short linksAuthenticates signing API calls
HolderFunctionFunction and business server
Impact of leakageAllows forging valid linksAllows requesting signed file links
Rotation impactInvalidates historical short links in this stateless designDoes not affect links; callers need the new credential

Manage the signing secret and signing API credential separately. Do not distribute SIGN_SECRET to business callers.

5.6 APIs and Parameters​

Signing endpoint

POST /s/_sign

Request headers:

HeaderRequiredDescription
x-sign-keyYesEquals the function's SIGN_ADMIN_KEY
content-typeYesapplication/json

Request body:

FieldRequiredDescription
pathYes, alternatively use bucket and keyCombined bucket and object path, such as bucket1/docs/a.pdf
bucket, keyYes, alternatively use pathSeparate bucket name and object path
ttlNoValidity in seconds; defaults to DEFAULT_TTL_SECONDS. Positive values are limited to 60 seconds through MAX_TTL_SECONDS seconds

DEFAULT_BUCKET supplies a missing bucket name. When using a combined path, the example API interprets the portion before the first / as the bucket. For multi-level keys, prefer separate bucket and key fields.

Response body:

FieldDescription
urlComplete sharing URL for the user
tokenSigned token in the short link
bucket, keyResolved bucket and object path
exp, expISOExpiration in Unix seconds and ISO 8601
ttlActual validity period

Download endpoint

GET /s/<token>, or HEAD on the same path.

No query parameters are used. Supports GET and HEAD; other methods return 405.

Platform storage API parameters

CallParameterDescription
cloudbase.init()envEnvironment ID
cloudbase.init()accessKeyServer API key; alternative to auth
cloudbase.init()auth.secretId, auth.secretKey, auth.sessionTokenTemporary credentials
app.storage.from()bucketBucket ID or name
.createSignedUrl()pathObject path within the bucket, without the bucket name
.createSignedUrl()expiresInPlatform-signed URL validity in seconds

Defaults and errors

ConditionBehavior
Missing SIGN_SECRET or SIGN_ADMIN_KEYSigning endpoint returns 500 server_not_configured
Missing CB_API_KEYFalls back to temporary credentials; downloads fail if permissions are insufficient
ttl omitted, non-positive, or non-numericUses DEFAULT_TTL_SECONDS, then applies the minimum and maximum
0 < ttl < 60Uses 60 seconds
ttl > MAX_TTL_SECONDSUses the maximum
Invalid path400 bad_file_path
Invalid signature, expired, or missing token403 link_invalid_or_expired
Missing object or no usable URL in the storage result404 not_found
Storage call throws502 sign_failed

These describe the example endpoint's behavior, not platform-wide error codes.

6. Integration​

6.1 Server Wrapper​

The following helper uses fetch in Node.js 18 or later:

const DOMAIN = process.env.SIGN_DOMAIN;
const ADMIN_KEY = process.env.SIGN_ADMIN_KEY;

async function createShareLink(bucket, key, ttl = 3600) {
if (!DOMAIN || !ADMIN_KEY) throw new Error('Missing signing configuration');
if (!bucket || !key) throw new Error('bucket and key are required');

const res = await fetch(`${DOMAIN}/s/_sign`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-sign-key': ADMIN_KEY,
},
body: JSON.stringify({ bucket, key, ttl }),
signal: AbortSignal.timeout(5000),
});

if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(`Signing failed ${res.status} ${detail.error || ''}`);
}

const { url, exp, expISO, ttl: actual } = await res.json();
return { url, exp, expiresAt: expISO, ttl: actual };
}

SIGN_DOMAIN is the sharing domain configured on your business server, such as https://dl.example.com. It is not one of the function variables listed above.

6.2 Business Endpoint​

Issue links only after authenticating the user and checking file access permissions. The route below assumes login middleware has set req.user, and that db and logger are provided by the business service.

app.post('/api/files/:id/share', async (req, res) => {
try {
if (!req.user?.id) return res.status(401).end();
const file = await db.file.findUnique({ where: { id: req.params.id } });
if (!file) return res.status(404).end();
if (file.ownerId !== req.user.id) return res.status(403).end();

const link = await createShareLink(file.bucket, file.key, 3600);
return res.json(link);
} catch (err) {
logger.error('create share link failed', err);
return res.status(502).json({ error: 'Unable to create share link' });
}
});
async function copyShareLink(fileId) {
const response = await fetch(`/api/files/${encodeURIComponent(fileId)}/share`, {
method: 'POST',
});
if (!response.ok) throw new Error('Unable to create share link');
const { url } = await response.json();
await navigator.clipboard.writeText(url);
}

The Clipboard API requires a secure context and can fail due to browser or permission restrictions. Older browsers can use this fallback example; on failure, prompt users to copy manually:

async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {}
}
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
try {
if (!document.execCommand('copy')) throw new Error('Copy manually');
} finally {
el.remove();
}
}

6.4 Permission Boundaries​

  1. Keep SIGN_SECRET, SIGN_ADMIN_KEY, and CB_API_KEY on the server only, never in frontend builds.
  2. The business endpoint must verify the current user's file access before calling the signing endpoint. HMAC ensures the link has not been tampered with; it does not determine whether the current user may obtain the file.

6.5 Common Errors​

StatusErrorCause
401unauthorizedx-sign-key does not match SIGN_ADMIN_KEY
400bad_file_pathInvalid bucket, object path, empty segment, or relative path
400bad_jsonRequest body is not valid JSON
403link_invalid_or_expiredDownload token is missing, invalid, or expired
404not_foundMissing object or insufficient credentials; no signed URL obtained
405method_not_allowedSigning request is not POST, or download request is not GET/HEAD
500server_not_configuredMissing signing secret or signing API credential
502sign_failedStorage call throws; check function logs

7. Verification Checklist​

TestExpected Result
Access within validity periodReturns a 302 redirect followed by the file
Modify signature characters403
Access the prefix without a token403
Access the short link again after its expiration403
Access a missing object404 during download
Modify k in the payload while keeping the signature403
Access a storage object without authorizationRejected; verify the bucket is not public
Request the platform-signed URL after its own expirationRejected

If you have prepared verify-signed-link.mjs from the original example project, run it with that project's configuration:

node verify-signed-link.mjs 'bucket1/docs/a.pdf'

This script is not a built-in CloudBase command. The script is not included in this page, and the command does not imply that online verification has been completed.

8. Troubleshooting​

SymptomCheck
Signing endpoint returns 401Compare x-sign-key with the server's SIGN_ADMIN_KEY
Short link returns 403Check token expiration, tampering, and signing secret rotation
Short link returns 404 with Object not foundCheck CB_API_KEY, RLS, and the object path
Short link returns 404 with Bucket not foundCheck the bucket name
502 with sign_failedCheck SDK initialization and storage call logs
Unauthorized direct URL still returns a fileCheck whether the bucket is public and review resource access policies
Short-link access bypasses the functionCheck for 301 redirects, cached responses, or CDN cache rules

9. Security Considerations​

  1. The bucket must be private. Verify that anonymous access is rejected so public object URLs cannot bypass the short link.
  2. A server API key is an environment-level privileged credential. Configure a dedicated key for this workload to simplify rotation and auditing.
  3. Rotating SIGN_SECRET invalidates historical short links. Previously generated platform URLs remain subject to their own expiration.
  4. Individual links cannot be revoked early. This design is stateless; for revocation, persist records keyed by j and check them during downloads.
  5. Keep no-store and disable short-link caching to avoid reusing cached redirects.
  6. The short link itself is a credential. Do not record complete URLs in logs, analytics, or Referer data.

10. Known Limitations​

  • Opening a short link changes the browser address bar to the platform-signed URL. Share the original /s/<token> link, not the redirect destination.
  • Use the platform-signed URL unchanged; do not replace its domain.
  • Temporary platform URLs can be reused within their own validity period. The default 60-second window does not make them single-use links.
  • Each new request to an uncached short link invokes the function to verify and generate a temporary URL. The storage path returns the file content, not the business function.
  • Expiration cannot revoke downloaded files or immediately invalidate a temporary platform URL obtained before the download.