Last active
August 24, 2025 01:23
-
-
Save joncardasis/d834e4201c9e3c2b97011c1c4a730039 to your computer and use it in GitHub Desktop.
Calculate presigned S3 url locally and synchronously in Node.js (no async/await)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import crypto from 'crypto' | |
import {parseResourceUrl} from './utils/parseResourceUrl' | |
interface PresignedUrlOptions { | |
/** Url in the format `https://${bucket}.s3.${region}.amazonaws.com/${key}` */ | |
awsResourceUrl: string | |
expiresIn?: number | |
} | |
/** Creates a locally presigned AWS resource url */ | |
export const makePresignedUrl = ({ | |
awsResourceUrl, | |
expiresIn = 3600, | |
}: PresignedUrlOptions) => { | |
const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID | |
const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY | |
if (!awsAccessKeyId) { | |
throw new Error('AWS_ACCESS_KEY_ID env variable is not defined') | |
} | |
if (!awsSecretAccessKey) { | |
throw new Error('AWS_SECRET_ACCESS_KEY env variable is not defined') | |
} | |
const parsedUrl = parseResourceUrl(awsResourceUrl) | |
if (!parsedUrl) { | |
throw new Error(`Failed to parse awsResourceUrl ${awsResourceUrl}`) | |
} | |
// Create ISO 8601 timestamp | |
const now = new Date() | |
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '') | |
const dateStamp = amzDate.substr(0, 8) | |
// Create credential scope | |
const credentialScope = `${dateStamp}/${parsedUrl.region}/s3/aws4_request` | |
const credential = `${awsAccessKeyId}/${credentialScope}` | |
// URI encode the key (but keep / as /) | |
const encodedKey = parsedUrl.key | |
.split('/') | |
.map(segment => encodeURIComponent(segment).replace(/%20/g, '+')) | |
.join('/') | |
// Build query parameters for the presigned URL | |
const queryParams: Record<string, string> = { | |
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', | |
'X-Amz-Credential': credential, | |
'X-Amz-Date': amzDate, | |
'X-Amz-Expires': expiresIn.toString(), | |
'X-Amz-SignedHeaders': 'host', | |
} | |
// Sort query parameters alphabetically for canonical request | |
const sortedParams = Object.keys(queryParams).sort() | |
const canonicalQueryString = sortedParams | |
.map( | |
key => | |
`${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}` | |
) | |
.join('&') | |
// Create canonical headers | |
const host = `${parsedUrl.bucket}.s3.${parsedUrl.region}.amazonaws.com` | |
const canonicalHeaders = `host:${host}\n` | |
const signedHeaders = 'host' | |
// Create canonical request | |
const canonicalRequest = [ | |
'GET', | |
`/${encodedKey}`, | |
canonicalQueryString, | |
canonicalHeaders, | |
signedHeaders, | |
'UNSIGNED-PAYLOAD', | |
].join('\n') | |
// Create string to sign | |
const algorithm = 'AWS4-HMAC-SHA256' | |
const stringToSign = [ | |
algorithm, | |
amzDate, | |
credentialScope, | |
crypto.createHash('sha256').update(canonicalRequest).digest('hex'), | |
].join('\n') | |
// Calculate signature | |
const signingKey = calculateSignatureKey( | |
awsSecretAccessKey, | |
dateStamp, | |
parsedUrl.region, | |
's3' | |
) | |
const signature = crypto | |
.createHmac('sha256', signingKey) | |
.update(stringToSign) | |
.digest('hex') | |
// Build final URL with signature | |
const finalParams = new URLSearchParams() | |
finalParams.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256') | |
finalParams.append('X-Amz-Credential', credential) | |
finalParams.append('X-Amz-Date', amzDate) | |
finalParams.append('X-Amz-Expires', expiresIn.toString()) | |
finalParams.append('X-Amz-Signature', signature) | |
finalParams.append('X-Amz-SignedHeaders', 'host') | |
const presignedUrl = `https://${host}/${parsedUrl.key}?${finalParams.toString()}` | |
// Calculate expiration time | |
const expiresAt = new Date(now) | |
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn) | |
return { | |
url: presignedUrl, | |
expiresAt, | |
} | |
} | |
const calculateSignatureKey = ( | |
key: string, | |
dateStamp: string, | |
regionName: string, | |
serviceName: string | |
) => { | |
const kDate = crypto | |
.createHmac('sha256', `AWS4${key}`) | |
.update(dateStamp) | |
.digest() | |
const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest() | |
const kService = crypto | |
.createHmac('sha256', kRegion) | |
.update(serviceName) | |
.digest() | |
const kSigning = crypto | |
.createHmac('sha256', kService) | |
.update('aws4_request') | |
.digest() | |
return kSigning | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const S3BucketName = "MY_BUCKET_NAME" | |
interface UrlParseResult { | |
bucket: string | |
key: string | |
region: string | |
} | |
/** Parses url in the format `https://${bucket}.s3.${region}.amazonaws.com/${key}` */ | |
export const parseResourceUrl = (url: string): UrlParseResult | null => { | |
try { | |
const parsedUrl = new URL(url) | |
// Match pattern: bucket.s3.region.amazonaws.com or bucket.s3.amazonaws.com (us-east-1) | |
const match = parsedUrl.hostname.match( | |
/^(.+?)\.s3(?:\.(.+?))?\.amazonaws\.com$/ | |
) | |
if (!match) return null | |
const bucket = match[1] | |
const region = match[2] || 'us-east-1' // Default region if not specified | |
const key = parsedUrl.pathname.substring(1) // Remove leading slash | |
// Validate required fields and bucket match | |
if (!key || bucket !== S3Configuration.Bucket) return null | |
return {bucket, key, region} | |
} catch { | |
return null | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment