Skip to content

Instantly share code, notes, and snippets.

@joncardasis
Last active August 24, 2025 01:23
Show Gist options
  • Save joncardasis/d834e4201c9e3c2b97011c1c4a730039 to your computer and use it in GitHub Desktop.
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)
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
}
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