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
| Requirement | Description |
|---|---|
| Custom domain | The sharing URL is https://dl.example.com/s/xxxx |
| Signature | Validate file and expiration parameters and reject tampered tokens |
| Expiration | Reject expired short links without caching their redirects |
| No file proxying through the business service | The 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.
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" }
| Field | Meaning |
|---|---|
b | Storage bucket |
k | Object path within the bucket |
e | Expiration time in Unix seconds |
j | Random 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
| Type | Duration | Controls | Configuration |
|---|---|---|---|
| Short-link token | Defaults to 1 hour, maximum 7 days | Whether the sharing link can obtain a temporary URL | ttl when issuing the link |
| Platform-signed URL | Defaults to 60 seconds | Whether new requests can access the redirect destination | REDIRECT_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:
| Method | Purpose |
|---|---|
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
| Approach | Description |
|---|---|
| Server API key | Uses the service_role role and is passed as accessKey during SDK initialization; server-side only |
| Temporary credentials and RLS | Uses 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
| Variable | Purpose | Format or Source | If Missing |
|---|---|---|---|
SIGN_SECRET | HMAC secret shared by signing and verification | Base64URL encoding of 32 random bytes | Signing endpoint returns 500 server_not_configured |
SIGN_ADMIN_KEY | Credential for /s/_sign, compared against x-sign-key | Base64URL encoding of 24 random bytes | Signing endpoint returns 500 server_not_configured |
TCB_ENV | Environment ID for the storage SDK | Target environment ID | Storage initialization cannot work correctly |
Recommended
| Variable | Purpose | Format or Source | If Missing |
|---|---|---|---|
CB_API_KEY | service_role server API key for createSignedUrl | Create and inject into function environment variables | Falls back to temporary credentials; insufficient permissions prevent obtaining download URLs |
Optional
| Variable | Default | Purpose |
|---|---|---|
REDIRECT_MAX_AGE | 60 | Platform-signed URL validity in seconds |
DEFAULT_TTL_SECONDS | 3600 | Short-link default validity when ttl is omitted |
MAX_TTL_SECONDS | 604800 | Maximum short-link validity |
DEFAULT_BUCKET | Empty | Default bucket when none is explicitly provided |
Injected by the runtime; no manual configuration
| Variable | Purpose |
|---|---|
TENCENTCLOUD_SECRETID, TENCENTCLOUD_SECRETKEY, TENCENTCLOUD_SESSIONTOKEN | Function temporary credentials, used only when CB_API_KEY is absent; all three are required |
Secret boundaries
| Comparison | SIGN_SECRET | SIGN_ADMIN_KEY |
|---|---|---|
| Purpose | Signs short links | Authenticates signing API calls |
| Holder | Function | Function and business server |
| Impact of leakage | Allows forging valid links | Allows requesting signed file links |
| Rotation impact | Invalidates historical short links in this stateless design | Does 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:
| Header | Required | Description |
|---|---|---|
x-sign-key | Yes | Equals the function's SIGN_ADMIN_KEY |
content-type | Yes | application/json |
Request body:
| Field | Required | Description |
|---|---|---|
path | Yes, alternatively use bucket and key | Combined bucket and object path, such as bucket1/docs/a.pdf |
bucket, key | Yes, alternatively use path | Separate bucket name and object path |
ttl | No | Validity 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:
| Field | Description |
|---|---|
url | Complete sharing URL for the user |
token | Signed token in the short link |
bucket, key | Resolved bucket and object path |
exp, expISO | Expiration in Unix seconds and ISO 8601 |
ttl | Actual 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
| Call | Parameter | Description |
|---|---|---|
cloudbase.init() | env | Environment ID |
cloudbase.init() | accessKey | Server API key; alternative to auth |
cloudbase.init() | auth.secretId, auth.secretKey, auth.sessionToken | Temporary credentials |
app.storage.from() | bucket | Bucket ID or name |
.createSignedUrl() | path | Object path within the bucket, without the bucket name |
.createSignedUrl() | expiresIn | Platform-signed URL validity in seconds |
Defaults and errors
| Condition | Behavior |
|---|---|
Missing SIGN_SECRET or SIGN_ADMIN_KEY | Signing endpoint returns 500 server_not_configured |
Missing CB_API_KEY | Falls back to temporary credentials; downloads fail if permissions are insufficient |
ttl omitted, non-positive, or non-numeric | Uses DEFAULT_TTL_SECONDS, then applies the minimum and maximum |
0 < ttl < 60 | Uses 60 seconds |
ttl > MAX_TTL_SECONDS | Uses the maximum |
Invalid path | 400 bad_file_path |
| Invalid signature, expired, or missing token | 403 link_invalid_or_expired |
| Missing object or no usable URL in the storage result | 404 not_found |
| Storage call throws | 502 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' });
}
});
6.3 Copy Links in the Browser
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
- Keep
SIGN_SECRET,SIGN_ADMIN_KEY, andCB_API_KEYon the server only, never in frontend builds. - 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
| Status | Error | Cause |
|---|---|---|
| 401 | unauthorized | x-sign-key does not match SIGN_ADMIN_KEY |
| 400 | bad_file_path | Invalid bucket, object path, empty segment, or relative path |
| 400 | bad_json | Request body is not valid JSON |
| 403 | link_invalid_or_expired | Download token is missing, invalid, or expired |
| 404 | not_found | Missing object or insufficient credentials; no signed URL obtained |
| 405 | method_not_allowed | Signing request is not POST, or download request is not GET/HEAD |
| 500 | server_not_configured | Missing signing secret or signing API credential |
| 502 | sign_failed | Storage call throws; check function logs |
7. Verification Checklist
| Test | Expected Result |
|---|---|
| Access within validity period | Returns a 302 redirect followed by the file |
| Modify signature characters | 403 |
| Access the prefix without a token | 403 |
| Access the short link again after its expiration | 403 |
| Access a missing object | 404 during download |
Modify k in the payload while keeping the signature | 403 |
| Access a storage object without authorization | Rejected; verify the bucket is not public |
| Request the platform-signed URL after its own expiration | Rejected |
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
| Symptom | Check |
|---|---|
| Signing endpoint returns 401 | Compare x-sign-key with the server's SIGN_ADMIN_KEY |
| Short link returns 403 | Check token expiration, tampering, and signing secret rotation |
| Short link returns 404 with Object not found | Check CB_API_KEY, RLS, and the object path |
| Short link returns 404 with Bucket not found | Check the bucket name |
| 502 with sign_failed | Check SDK initialization and storage call logs |
| Unauthorized direct URL still returns a file | Check whether the bucket is public and review resource access policies |
| Short-link access bypasses the function | Check for 301 redirects, cached responses, or CDN cache rules |
9. Security Considerations
- The bucket must be private. Verify that anonymous access is rejected so public object URLs cannot bypass the short link.
- A server API key is an environment-level privileged credential. Configure a dedicated key for this workload to simplify rotation and auditing.
- Rotating
SIGN_SECRETinvalidates historical short links. Previously generated platform URLs remain subject to their own expiration. - Individual links cannot be revoked early. This design is stateless; for revocation, persist records keyed by
jand check them during downloads. - Keep
no-storeand disable short-link caching to avoid reusing cached redirects. - 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.