S3 Presigned URL VS CloudFront Signed URL thumbnail
S3 Presigned URL VS CloudFront Signed URL

2025-04-15

S3 Presigned URL กับ CloudFront Signed URL ต่างกันยังไง? มีใครเคยสงสัยแบบผมมั้ยครับ คือตอนนี้ผมก็รู้แล้วแหละว่ามันต่างกันยังไงเพราะมันแทบจะคนละเรื่องคนละ service กันเลย แต่สมัยที่ผมพึ่งใช้ AWS ใหม่ ๆ ด้วยความที่ประสบการณ์แทบจะเป็น 0 แถมชื่อ presigned URL กับ signed URL ยังคล้ายกันมากจนผมเผลอคิดไปว่ามันคือสิ่งเดียวกันแต่แค่ AWS อินดี้ตั้งชื่อให้ไม่เหมือนกันเฉย ๆ ครับ 555

เพราะฉะนั้นบทความนี้เลยจะเอาเครื่องมือทั้ง 2 ตัวนี้มาอธิบายว่ามันคืออะไร ส่วนที่ว่ามันต่างกันยังไงเนี่ย แค่รู้ว่าเครื่องมือแต่ละอันมันคืออะไรก็น่าจะบอกความแตกต่างได้ไม่ยากแล้วครับ

S3 Presigned URL

ต้องบอกก่อนครับว่าโดยปกติแล้ว S3 bucket เนี่ยจะไม่ยอมให้ใครมายุ่งกับมันทั้งนั้นครับ เว้นแต่ว่าเราจะมี permission เพียงพอ เช่น ถ้าจะดาวน์โหลด object ก็ต้องมี s3:getObject หรืออัพโหลด object ก็ต้องมี s3:putObject เป็นต้น แถมพวก guardrail ต่าง ๆ ของ S3 เช่น S3 bucket policy, ACL ก็ต้องเปิดทางให้เราด้วยนะครับ

ปัญหาอย่างนึงที่อาจจะเคยเจอกันก็คือเวลาที่เราต้องการจะแชร์ไฟล์อะไรซักอย่างที่อยู่ใน S3 ให้กับคนนอกที่เขาไม่ได้มี permission หรือไม่มีแม้กระทั่ง IAM role/user ที่จะเข้ามาโหลดไฟล์ไปเองได้ ซึ่งก็ต้องแก้ปัญหาเฉพาะหน้ากันไป เช่น ให้คนที่มี permission โหลดไฟล์ให้แทน หรือยอมเปิด public access ให้ไฟล์นั้นเป็นการชั่วคราว เป็นต้น

ในเวลาแบบนี้แหละครับที่ S3 presigned URL จะได้เฉิดฉาย

มันคืออะไร

S3 presigned URL คือ URL ที่เอาไว้ใช้สำหรับโหลดไฟล์ใน S3 ครับ แต่มันจะแตกต่างจาก URL ทั่วไปตรงที่จะมี permission สำหรับโหลดไฟล์แนบมากับ URL ด้วยแบบพร้อมใช้งานเลย ทีนี้พอเวลามีคนมาโหลดไฟล์โดยใช้ presigned URL S3 ก็จะเช็ค permission ที่อยู่ใน URL แทน นั่นหมายความว่าเราสามารถแชร์ไฟล์ใน S3 ได้ด้วย URL อันเดียวโดยไม่ต้องคิดอะไรให้ซับซ้อนแล้วครับ

แล้ว permission ที่ติดมากับ URL นี่มาจากไหนล่ะ คำตอบก็คือมาจากคนที่สร้าง presigned URL ครับ การสร้าง presigned URL คือการที่ user คนหนึ่ง (สมมติว่าเป็นนาย a) เอา permission ในการโหลดไฟล์จาก S3 ของตัวเองไปให้คนอื่นยืมในรูปแบบของ URL ครับ พออีกคนนึง (นาย b) มาโหลดไฟล์จาก S3 โดยใช้ presigned URL ที่ได้รับมา ใน CloudTrail ถ้าตั้งค่า Data Event ให้เก็บ S3 event เอาไว้ ก็จะมี event log ระบุเอาไว้ว่าคนที่สร้าง presigned URL (นาย a) เป็นคนเรียก api getObject เพื่อโหลดไฟล์จาก s3

เนื่องจาก presigned URL เป็นการ “ให้ยืม” permission นั่นความว่าคนสร้าง presigned URL เองจำเป็นจะต้องมี permission นั้นอยู่กับตัวก่อนถึงจะสามารถสร้าง URL ได้

ซึ่งถ้ามองอีกมุมนึง มันก็คล้าย ๆ กับการที่นาย a โหลดไฟล์มาแล้วส่งไฟล์ต่อให้นาย b นั่นแหละ เพียงแต่แทนที่นาย a จะให้ไฟล์ไปตรง ๆ ก็ให้ยืมสิทธิ์ในการเข้าถึงไฟล์แทนแล้วให้นาย b ไปโหลดเอาเอง

มันดียังไง

การใช้ presigned URL ในการแชร์ไฟล์จะมีข้อดีกว่าการแชร์ไฟล์แบบทั่วไปตรงที่

  1. ใช้งานง่าย สามารถแชร์ไฟล์ให้คนอื่นได้โดยไม่ต้องตั้งค่า permission เพิ่มเติมให้คนรับไฟล์
  2. ปลอดภัยกว่า ไม่ต้องเสี่ยงเปิด public access
  3. มีประสิทธิภาพกว่า ลองนึกภาพการแชร์ไฟล์ใหญ่ ๆ ดูนะครับ ถ้าให้นาย a โหลดไฟล์แล้วส่งไปให้นาย b อันดับแรกนาย a จะต้องเสียเวลาโหลดไฟล์ จากนั้นต้องอัพโหลดไฟล์เพื่อส่งไปให้นาย b แล้วนาย b ก็ต้องเสียเวลาโหลดไฟล์เข้ามาอีก ซึ่งถ้าใช้ presigned URL แทน นาย b ก็แค่ไปโหลดไฟล์มาเองตรง ๆ แค่นั้นเลย

มันใช้ยังไง

สิบปากว่าไม่เท่าตาเห็น ลองไปดูจริงกันดีกว่าครับ

สมมติว่าผมมี S3 bucket ชื่อ my-bucket-09e54cfa (ตั้งชื่อ bucket ให้ unique นี่มันลำบากดีแท้) ที่ข้างในมีไฟล์ song-of-the-day.txt อยู่

01.png

สมมติว่าอยากแชร์ไฟล์นี้ให้คนอื่น เลย copy URL แล้วเปิดไฟล์จาก URL นั้นดู ก็จะพบว่าเปิดไม่ได้ เพราะไม่ได้เปิด public access ให้กับไฟล์นี้ไว้

02.png

ทีนี้ลองแชร์โดยใช้ presigned URL แทน โดยไปที่เมนู Actions → Share with a presigned URL จากนั้นใส่ระยะเวลาเข้าไปว่าอยากให้ URL นี้ใช้ได้นานเท่าไหร่ (สร้างผ่าน console จะเลือกได้สูงสุด 12 ชม. แต่ถ้าสร้างผ่าน CLI หรือ SDK จะได้สูงสุด 7 วัน)

03.png

04.png

แล้วลองเปิดไฟล์ด้วย presigned URL ที่ได้มาก็จะพบว่าสามารถเข้าถึงไฟล์ได้แล้ว

05.png

พอ URL หมดอายุก็จะใช้งานไม่ได้อีก

06.png

แถม

นอกจากใช้โหลดไฟล์แล้ว S3 presigned URL ยังสามารถใช้ทำอย่างอื่นได้ด้วยครับ ไม่ว่าจะเป็นอัปโหลดไฟล์เข้าไปใน bucket, list ไฟล์ที่อยู่ใน bucket, ฯลฯ เพียงแต่ถ้าเราอยากจะสร้าง presigned URL สำหรับทำอย่างอื่นที่ไม่ใช้ดาวน์โหลดไฟล์ จะต้องใช้ SDK ในการสร้างเท่านั้น

ตัวอย่าง code ด้านล่างนี้จะสร้าง presigned URL สำหรับอัปโหลดไฟล์ที่ชื่อ game-of-the-day.txt เข้าไปที่ bucket my-bucket-09e54cfa ซึ่ง URL นี้จะมีอายุอยู่ได้ 5 นาที

import boto3
from botocore.exceptions import ClientError

def generate_presigned_url():
    s3_client = boto3.client('s3')
    try:
        response = s3_client.generate_presigned_url(
            'put_object',
            Params={'Bucket': 'my-bucket-09e54cfa', 'Key': 'game-of-the-day.txt'},
            ExpiresIn=300,
        )
    except ClientError as e:
        logging.error(e)
        return None

    return response

เวลาใช้ก็สามารถใช้แบบ URL ทั่วไป ตัวอย่างเช่น ถ้าใช้กับ curl ก็จะเป็นประมาณด้านล่างนี้

curl -X PUT -T "game-of-the-day.txt" "<presigned-URL>"

07.png

หรืออย่างอันนี้จะสร้าง presigned URL สำหรับ list ไฟล์ที่อยู่ใน bucket my-bucket-09e54cfa

import boto3
from botocore.exceptions import ClientError

def generate_presigned_url():
    s3_client = boto3.client('s3')
    try:
        response = s3_client.generate_presigned_url(
            'list_objects',
            Params={'Bucket': 'my-bucket-09e54cfa'},
            ExpiresIn=300,
        )
    except ClientError as e:
        logging.error(e)
        return None

    return response

เอา presigned URL ที่ได้ไปเปิดบน browser ก็ได้จะข้อมูลกลับมาในรูปของ XML

08.png

สำหรับรายละเอียดเพิ่มเติม ถ้าใครสนใจวิธีการสร้าง presigned URL สำหรับใช้ทำอย่างอื่นนอกเหนือจากนี้สามารถดูได้ที่ documentation ของ AWS ได้เลยครับ

แต่จริง ๆ แล้วเราสามารถสร้าง presigned URL เองได้โดยไม่ต้องใช้ SDK เลยนะครับ แถมสามารถใช้กับ API ของ service อื่นที่ไม่ใช่ S3 ได้ด้วย ไว้ผมจะเขียนเกี่ยวกับเรื่องนี้ในครั้งหน้าครับ

CloudFront Signed URL

ลองนึกภาพตามนะครับ สมมติผมมีรูปอันนึงอยู่ใน S3 ที่เป็น origin ของ CloudFront ตราบใดที่รูปนี้ยังอยู่ใน S3 ขอแค่รู้ URL ของรูป ไม่ว่าใครก็สามารถดูได้ใช่มั้ยครับ แถมเปิดจากที่ไหนก็ได้ด้วย (ถ้าไม่ติด CORS)

แล้วก็มีกรณีที่บางเว็บไซต์ (เช่นเว็บนี้) ใช้ URL ที่สามารถคาดเดาได้ง่าย ทำให้รู้ URL แค่อันเดียวก็สามารถเข้าถึง resource ต่าง ๆ เช่น รูปภาพ, ฯลฯ แทบทั้งหมดได้จากการเดา pattern ของ URL เลยครับ

สำหรับเว็บไซต์นี้ปัญหาที่ว่ามาก็ไม่ได้ร้ายแรงอะไรขนาดนั้น เพราะเดิมทีก็ทำมาเพื่อเผยแพร่สู่สาธารณะอยู่แล้ว แต่หลาย ๆ เว็บไซต์ก็ไม่ต้องการแบบนั้น กลับกันอาจจะต้องการจำกัดการเข้าถึง resource เหล่านี้ เช่น ให้เข้าถึงได้ผ่านหน้าเว็บของตัวเองเท่านั้น, ให้ไม่สามารถเดา URL ได้, หรือจำกัดระยะเวลาการเข้าถึง เป็นต้น ซึ่ง signed URL มีไว้เพื่อการนี้เลยครับ

มันคืออะไร

CloudFront signed URL คือฟีเจอร์ของ CloudFront ที่เอา URL ของ CloudFront มา signed ด้วย cryptographic key จากนั้นเวลาเราเข้าถึง resource ผ่าน CloudFront ตัว CloudFront จะทำการตรวจ signature ที่ติดมากับ URL ก่อน ซึ่งถ้าไม่มี signature หรือ signature ผิดหรือหมดอายุก็จะไม่ส่ง resource กลับไปให้ซึ่งการทำแบบนี้จะช่วยแก้ปัญหาทั้งหมดที่กล่าวมาเมื่อกี้

  1. URL ของ resource แต่ละอันจะถูก sign แยกกัน ทำให้ไม่สามารถเดา URL ได้
  2. ตัว signature มีอายุจำกัด ทำให้ต่อให้รู้ URL ไปก็สามารถใช้เข้าถึง resource ได้ไม่นาน

มันใช้ยังไง

อธิบายคร่าว ๆ ก่อนครับ คือตอนนี้ผมมีรูปภาพ thumbnail.png อยู่ใน S3 bucket แล้วก็มี CloudFront distribution อันนึงที่มี bucket อันนี้เป็น origin ครับ หมายความว่าตอนนี้ผมสามารถดูรูปภาพนี้ได้ผ่าน <distribution_domain_name>/thumbnail.png

09.png

แล้วถ้าเกิดผมมีรูปอื่นอีก เช่น thumbnail2.png ผมก็แค่ไล่เปลี่ยนชื่อไฟล์ใน URL ไปเรื่อย ๆ ก็พอ เช่น <distribution_domain_name>/thumbnail2.png

ทีนี้เราจะใช้ signed URL ทำให้เราไม่สามารถเปิดรูปภาพด้วย URL ที่ว่ามานี่ได้อีกต่อไป โดยก่อนอื่นก็เริ่มจากเตรียม keypair ก่อน ซึ่งในครั้งนี้ผมจะใช้ openssl สร้าง keypair นี้ขึ้นมา (AWS ระบุไว้ว่า keypair ที่ใช้จะต้องเป็น SSH-2 RSA 2048-bit เท่านั้น)

openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private.pem -pubout -out public.pem

จากนั้นก็สร้าง public key บน CloudFront ด้วย public key ที่เราสร้างขึ้นมาเมื่อกี้

aws cloudfront create-public-key --public-key-config "CallerReference=my-public-key,Name=my-public-key,EncodedKey=$(cat public.pem)"

สร้าง key group แล้วใส่ public key เมื่อกี้เข้าไป (เราสามารถใส่ key เข้าไปใน key group ได้สูงสุด 5 อัน เผื่อกรณีที่ต้องการ rotate key)

aws cloudfront create-key-group --key-group-config "Name=my-key-group,Items=<public-key-id>"

สุดท้ายก็ตั้งค่าให้ distribution ใช้ public key ใน key group เพื่อตรวจสอบ signature ของ request ที่เข้ามา

เลือก distribution → behavior → edit → เปิด restrict viewer access → เลือก key group ที่สร้างไว้

10.png

11.png

พอตั้งค่าเสร็จแล้วลองเปิดรูปภาพด้วย URL เดิม จะพบว่าเปิดไม่ได้แล้ว เพราะไม่มี signature

12.png

ขั้นตอนต่อไปเราก็จะสร้าง signed URL โดยใช้ SDK ซึ่ง URL ที่ได้จาก code ด้านล่างจะสามารถใช้เพื่อเข้าถึงไฟล์ thumbnail.png จนถึงวันที่ 15/04/2025 หลังจากนั้น URL ก็จะหมดอายุครับ

NOTE: ในกรณีที่มี keypair หลายอัน สามารถเปลี่ยน private key และ keypair ID ที่ใช้เพื่อ rotate key ได้

import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
import { readFileSync } from "fs";

function signURL() {
  const URL = "https://d2wv1gnkwl6nia.cloudfront.net/thumbnail.png";
  const privateKey = readFileSync("./private.pem", "utf-8");
  const keyPairId = "K1YNLME0H5Y5BA";
  const dateLessThan = "2025-04-15";

  const signedUrl = getSignedUrl({
    URL,
    keyPairId,
    dateLessThan,
    privateKey,
  });

  return signedUrl;
}

ลองเปิด URL ดูก็จะพบว่าสามารถดูรูปภาพได้

13.png

เราสามารถใช้ wildcard กับ URL ได้ด้วย แต่ไม่ใช่ว่าแค่เปลี่ยน URL เป็น https://d2wv1gnkwl6nia.cloudfront.net/*.png แล้วจะจบนะครับ เพราะแบบนี้ URL ที่ได้มาจะหมายถึงอนุญาตให้เข้าถึงไฟล์ชื่อ *.png (* ที่ไม่ใช่ wildcard แต่หมายถึงไฟล์ที่ชื่อ * จริง ๆ แบบ literally) เฉย ๆ

การใช้ wildcard จำเป็นจะต้องระบุ policy เองครับ ซึ่ง policy เป็นตัวกำหนดว่า URL นี้สามารถเข้าถึงอะไรได้บ้าง โดยใน code ด้านบนเมื่อกี้เราไม่ได้ระบุ policy ลงไป ตัว library ก็เลยกำหนด policy แบบพื้นฐานที่สุดให้เราเอง ก็คือสามารถเข้าถึงได้แค่ไฟล์ชื่อ thumbnail.png ตามที่อยู่ใน URL เท่านั้น

ซึ่งพอเราระบุ policy เอง พวกข้อมูลอื่น ๆ เช่น URL, dateLessThan, ฯลฯ เราก็จะใส่เข้าไปใน policy เลย ไม่เอามาระบุแยกแบบ code ด้านบนเมื่อกี้ครับ

import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
import { readFileSync } from "fs";

export const handler = async (event) => {
  const URL = "https://d2wv1gnkwl6nia.cloudfront.net/*";
  const privateKey = readFileSync("./private.pem", "utf-8");
  const keyPairId = "K1YNLME0H5Y5BA";
  const dateLessThan = "2025-04-15";
  const policy = {
    Statement: [
      {
        Resource: URL,
        Condition: {
          DateLessThan: {
            "AWS:EpochTime": new Date(dateLessThan).getTime() / 1000,
          },
        },
      },
    ],
  };

  const policyString = JSON.stringify(policy);

  const signedUrl = getSignedUrl({
    keyPairId,
    privateKey,
    policy: policyString,
  });

  return signedUrl;
};

signed URL ที่ได้ออกมาก็จะหน้าตาคล้ายกับ URL ที่เรากำหนดไป เพียงแต่มี query string อื่น ๆ ติดมาด้วย (https://d2wv1gnkwl6nia.cloudfront.net/*?Policy=…) ซึ่งเราก็แค่ต้องเปลี่ยน * เป็น path ที่เราต้องการก็พอ เช่น https://d2wv1gnkwl6nia.cloudfront.net/thumbnail.png?Policy=…

แถม

อันนี้เป็นแค่ความคิดเห็นส่วนตัวนะครับ แต่ผมคิดว่า signed URL เนี่ยมีข้อเสียอยู่อย่างนึง คือ URL ของเราจะเปลี่ยนครับ ทำให้ใช้กับเว็บไซต์ที่เป็น static ได้ค่อนข้างลำบาก เนื่องจากต้องคอย sign URL อยู่เรื่อย ๆ ทำให้ URL พวกนี้ก็จะเปลี่ยนไปเรื่อย ๆ เหมือนกัน ซึ่งก็จะทำให้ไฟล์ HTML มันไม่ static ขึ้นมา หรือต่อให้ไป sign ที่ฝั่ง client ก็ต้องใช้ private key อยู่ดี นั่นหมายความว่าจะต้องให้ฝั่ง client สามารถเข้าถึง private key ได้ (อย่าหาทำ)

ซึ่งนอกจาก signed URL แล้วก็ยังมีอีกตัวเลือกนึงคือ signed cookies ที่การทำงานคล้าย ๆ กับ signed URL เพียงแต่เปลี่ยนที่เก็บ signature จาก URL เป็น cookies แทน โดยทั้งคู่ก็จะมี use case ที่เหมาะสมและไม่เหมาะสมอยู่ซึ่งเดี๋ยวผมจะเขียนถึงในโอกาสหน้าครับ

อ้างอิง