ЁЯПл The SchoolтА║ЁЯЫОя╕П API GatewayтА║ЁЯМР рдзрдбрд╛ 09 тАФ CORS: browser рдЖрдзреА рд╡рд┐рдЪрд╛рд░рддреЛ
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯМР рдзрдбрд╛ 09 тАФ CORS: browser рдЖрдзреА рд╡рд┐рдЪрд╛рд░рддреЛ

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 09 ┬╖ рдорд╛рдЧреЗ: lesson-08-caching ┬╖ рдкреБрдвреЗ: lesson-10-custom-domains


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ08, рдЖрдгрд┐ CORS: browser рдЖрдзреА рдкрд╛рдард╡рддреЛ рддреА preflight OPTIONS request, office рдЙрддреНрддрд░рд╛рдд рджреЗрддреЗ рддреЗ Access-Control-Allow-* headers, рдЖрдгрд┐ CORS рддреБрдордЪреНрдпрд╛ API рдЪреЗ рдирд╛рд╣реА рддрд░ browsers рдЪреЗ рд╕рдВрд░рдХреНрд╖рдг рдХрд╛ рдХрд░рддреЗ. apigw/demo.py рдордзрд▓реЗ cors() рд╢рд╛рд│реЗрдЪреНрдпрд╛ рд╕реНрд╡рддрдГрдЪреНрдпрд╛ web site рдХрдбреВрди рдЖрдгрд┐ рдПрдХрд╛ рдЕрдиреЛрд│рдЦреА site рдХрдбреВрди рдЖрд▓реЗрд▓реНрдпрд╛ preflight рд▓рд╛ рдЙрддреНрддрд░ рджреЗрддреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдкрд╛рд▓рдХрд╛рдВрдЪрд╛ browser ЁЯМР рд╣рд╛ рдЕрддрд┐рд╢рдп рдХрд╛рд│рдЬреАрдкреВрд░реНрд╡рдХ рд╡рд╛рдЧрдгрд╛рд░рд╛ рдорджрддрдиреАрд╕ рдЖрд╣реЗ. рдкрд╛рд▓рдХ рд╢рд╛рд│реЗрдЪреЗ web page, https://school.example, рд╡рд╛рдЪрдд рдЖрд╣реЗрдд. рддреЗ page browser рд▓рд╛ рд╡рд┐рдЪрд╛рд░рддреЗ: "рдХреГрдкрдпрд╛ рдРрд╢реНрд╡рд░реНрдпрд╛рдЪреЗ рдЧреБрдг https://api.school.example рд╡рд░реВрди рдЖрдгреВрди рджреЗ". рддреЛ рджреБрд╕рд░рд╛ рдкрддреНрддрд╛ рдЖрд╣реЗ.

рдореНрд╣рдгреВрди browser рдЖрдзреА office рдЪреНрдпрд╛ рджрд╛рд░рд╛рд╡рд░ рдЯрдХрдЯрдХ рдХрд░рддреЛ рдЖрдгрд┐ рд╡рд┐рдЪрд╛рд░рддреЛ: "school.example рд╡рд░рдЪреНрдпрд╛ page рд▓рд╛ GET рдкрд╛рдард╡рд╛рдпрдЪрд╛ рдЖрд╣реЗ. рдЪрд╛рд▓реЗрд▓ рдХрд╛?" рд╣реА рдЯрдХрдЯрдХ рдореНрд╣рдгрдЬреЗ preflight (OPTIONS).

Clerk рдЙрддреНрддрд░ рджреЗрддреЛ: "рд╣реЛ тАФ school.example рд╕рд╛рдареА, рдпрд╛ methods, рд╣реЗ headers, рдЖрдгрд┐ рд╣реЗ рдЙрддреНрддрд░ рддреВ 10 рдорд┐рдирд┐рдЯреЗ рд▓рдХреНрд╖рд╛рдд рдареЗрд╡реВ рд╢рдХрддреЛрд╕." рдордЧ browser рдЦрд░реА request рдкрд╛рдард╡рддреЛ.

evil.example рд╡рд░рдЪреЗ рдПрдХ page рддреЗрдЪ рд╡рд┐рдЪрд╛рд░рддреЗ. Clerk рдирд╛рд╣реА рдореНрд╣рдгрддреЛ. рдордЧ browser рддреНрдпрд╛ page рд▓рд╛ рдЙрддреНрддрд░ рджреНрдпрд╛рдпрд▓рд╛ рдирдХрд╛рд░ рджреЗрддреЛ.

рд▓рдХреНрд╖ рджреНрдпрд╛: рдЖрдЬреНрдЮрд╛ рдкрд╛рд│рддреЛ рддреЛ browser. curl рд╡рд╛рдкрд░рдгрд╛рд░реА рд╡реНрдпрдХреНрддреА рдХрдзреАрдЪ рдЖрдзреА рд╡рд┐рдЪрд╛рд░рдд рдирд╛рд╣реА тАФ clerk рдЪреЗ "рдирд╛рд╣реА" рддрд┐рд▓рд╛ рдерд╛рдВрдмрд╡рдд рдирд╛рд╣реА.

ЁЯЧ║я╕П рдЖрдХреГрддреА

sequenceDiagram
    participant P as ЁЯУД page on school.example
    participant B as ЁЯМР browser
    participant O as ЁЯЫОя╕П front office
    P->>B: fetch api/students/7
    B->>O: OPTIONS /students/7 ┬╖ Origin: https://school.example
    O-->>B: 204 ┬╖ Allow-Origin: https://school.example ┬╖ Max-Age: 600
    B->>O: GET /students/7 ┬╖ Origin: https://school.example
    O-->>B: 200 ┬╖ Access-Control-Allow-Origin: https://school.example
    B-->>P: the answer
    Note over B,O: Origin https://evil.example тЖТ 403, and the browser blocks the page

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрдХреГрддреА + рдПрдХ lab: https://school-edh.pages.dev/apigateway/lesson-diagrams.html#l09

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рдЬрд╡рд│рдЬрд╡рд│ рдкреНрд░рддреНрдпреЗрдХ web app рджреБрд╕рд▒реНрдпрд╛ origin рд╡рд░рдЪрд╛ API call рдХрд░рддреЗ, рдЖрдгрд┐ header рдирд╕рдгреЗ рд╣рд╛ рд╕рдЧрд│реНрдпрд╛рдд рд╕рд╛рдорд╛рдиреНрдп "curl рдордзреНрдпреЗ рдЪрд╛рд▓рддреЗ рдкрдг browser рдордзреНрдпреЗ рдирд╛рд╣реА" bug рдЖрд╣реЗ. рдЖрдгрд┐ рдХрд╛рд░рдг рдЕрдиреЗрдХрд╛рдВрдирд╛ рд╡рд╛рдЯрддреЗ рдХреА CORS рд╣реА API рд╕рд╛рдареА security рднрд┐рдВрдд рдЖрд╣реЗ. рддрд╕реЗ рдирд╛рд╣реА: рддреА user рдЪреНрдпрд╛ browser рдЪреЗ рдЕрд╢рд╛ pages рдкрд╛рд╕реВрди рд╕рдВрд░рдХреНрд╖рдг рдХрд░рддреЗ рдЬреА user рдЪреНрдпрд╛ cookies рд╡рд╛рдкрд░реВрди data рд╡рд╛рдЪрд╛рдпрдЪрд╛ рдкреНрд░рдпрддреНрди рдХрд░рддрд╛рдд. рддреБрдордЪреНрдпрд╛ API рд▓рд╛ auth рд▓рд╛рдЧрддреЛрдЪ (рдзрдбрд╛ 06).

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

apigw/gateway.py рдордзрд▓рд╛ Gateway._handle() рдкреНрд░рддреНрдпреЗрдХ OPTIONS рд▓рд╛ route matching рдЪреНрдпрд╛ рдЖрдзреАрдЪ рд╕реНрд╡рддрдГ рдЙрддреНрддрд░ рджреЗрддреЛ (HTTP API рдЪреНрдпрд╛ CORS setting рдкреНрд░рдорд╛рдгреЗ): cors_origins рдордзрд▓реНрдпрд╛ origin рд▓рд╛ рдЪрд╛рд░ Access-Control-* headers рд╕рд╣ 204 рдорд┐рд│рддреЛ; рдЗрддрд░ рдХреЛрдгрддреНрдпрд╛рд╣реА origin рд▓рд╛ 403 CORS origin not allowed рдорд┐рд│рддреЛ. Request рдЪрд╛ Origin рдкрд░рд╡рд╛рдирдЧреА рдЕрд╕рд▓реЗрд▓рд╛ рдЕрд╕реЗрд▓ рддреЗрд╡реНрд╣рд╛ Gateway.handle() рдЦрд▒реНрдпрд╛ рдЙрддреНрддрд░рд╛рддрд╣реА Access-Control-Allow-Origin рдШрд╛рд▓рддреЛ. office() рдиреЗрдордХрд╛ рдПрдХрдЪ origin рдкрд░рд╡рд╛рдирдЧреА рджреЗрддреЛ: https://school.example.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 apigw/demo.py cors
python3 - <<'EOF'
import sys; sys.path.insert(0, "apigw"); from demo import office
gw, _ = office()
for origin in ("https://school.example", "http://school.example", "https://school.example.evil.com"):
    st, hd, _ = gw.handle("OPTIONS", "/marks", {"Origin": origin, "Access-Control-Request-Method": "POST"})
    print(st, origin, hd.get("Access-Control-Allow-Origin"))
st, hd, out = gw.handle("GET", "/students/9", {"Origin": "https://evil.example"})
print("GET from evil.example:", st, out, "┬╖ Allow-Origin header:", hd.get("Access-Control-Allow-Origin"))
EOF

рдЪрд╛рд▓реВ office рд╡рд░ рддреБрдореНрд╣реАрдЪ browser рдмрдирд╛:

python3 apigw/demo.py serve &
sleep 1                                # give the office a second to open
curl -i -X OPTIONS -H "Origin: https://school.example" -H "Access-Control-Request-Method: GET" localhost:8080/students/7
curl -i -X OPTIONS -H "Origin: https://evil.example" localhost:8080/students/7
curl -i -H "Origin: https://evil.example" localhost:8080/students/9
kill %1

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

cors рдЫрд╛рдкрддреЗ OPTIONS /students/7 тЖТ 204 Access-Control-Allow-Origin: https://school.example Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: authorization,content-type,x-api-key Access-Control-Max-Age: 600, рдордЧ https://evil.example рд╕рд╛рдареА OPTIONS /students/7 тЖТ 403 {'message': 'CORS origin not allowed'}, рдордЧ X-Cache: Miss Access-Control-Allow-Origin: https://school.example рд╕рд╣ рдЦрд░рд╛ GET.

рддреБрдордЪрд╛ snippet рдЫрд╛рдкрддреЛ 204 https://school.example https://school.example, 403 http://school.example None (рд╡реЗрдЧрд│рд╛ scheme рдореНрд╣рдгрдЬреЗ рд╡реЗрдЧрд│рд╛ origin) рдЖрдгрд┐ 403 https://school.example.evil.com None (рд╕рд╛рд░рдЦреЗ рджрд┐рд╕рдгрд╛рд░реЗ рдирд╛рд╡ рдореНрд╣рдгрдЬреЗ рддреЛрдЪ origin рдирд╛рд╣реА). рд╢реЗрд╡рдЯрдЪреА рдУрд│ рд╕рдЧрд│реНрдпрд╛рдд рдорд╣рддреНрддреНрд╡рд╛рдЪреА рдЖрд╣реЗ: GET from evil.example: 200 {'id': '9', 'name': 'Katrina', 'class': '9A'} ┬╖ Allow-Origin header: None тАФ office рдиреЗ рдЙрддреНрддрд░ рджрд┐рд▓реЗрдЪ; рдлрдХреНрдд browser рддреЗ рдЙрддреНрддрд░ page рдкрд╛рд╕реВрди рд▓рдкрд╡реЗрд▓.

рдЪрд╛рд▓реВ office рд╡рд░: рдЪрд╛рд░ Access-Control-* headers рд╕рд╣ HTTP/1.0 204 No Content; {"message": "CORS origin not allowed"} рд╕рд╣ HTTP/1.0 403 Forbidden; рдЖрдгрд┐ evil.example рдХрдбреВрди рдЖрд▓реЗрд▓реНрдпрд╛ рд╕рд╛рдзреНрдпрд╛ GET рд╕рд╛рдареА data рд╕рд╣ HTTP/1.0 200 OK, рдкрдг Access-Control-Allow-Origin header рдирд╛рд╣реА.

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рддреБрдореНрд╣реА browser рдкреНрд░рдорд╛рдгреЗ preflight рд╡рд╛рдЪреВ рд╢рдХрддрд╛, рдЖрдгрд┐ рддреБрдореНрд╣реА рд╕реНрд╡рддрдГрдЪреНрдпрд╛ рдбреЛрд│реНрдпрд╛рдВрдиреА рдкрд╛рд╣рд┐рд▓реЗ рдХреА CORS curl рд▓рд╛ рдерд╛рдВрдмрд╡рдд рдирд╛рд╣реА тАФ рддреЛ browsers рдкрд╛рд│рддрд╛рдд рддреЛ рдирд┐рдпрдо рдЖрд╣реЗ, API рд╡рд░рдЪреЗ рдХреБрд▓реВрдк рдирд╛рд╣реА.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ HTTP API рд╡рд░ CORS рдореНрд╣рдгрдЬреЗ рдПрдХрдЪ setting:

aws apigatewayv2 update-api --api-id a1b2c3 --cors-configuration \
    AllowOrigins=https://school.example,AllowMethods=GET,POST,AllowHeaders=authorization,content-type,MaxAge=600

REST API рд╡рд░, browser pages рдирд╛ office рдЪреЗ рд╕реНрд╡рддрдГрдЪреЗ errors рд╕реБрджреНрдзрд╛ рд╡рд╛рдЪреВ рджреНрдпрд╛:

cat > cors-4xx.json <<'EOF'
{ "gatewayresponse.header.Access-Control-Allow-Origin": "'https://school.example'" }
EOF
aws apigateway put-gateway-response --rest-api-id abc123 --response-type DEFAULT_4XX \
    --response-parameters file://cors-4xx.json

(рдЖрддрд▓реЗ single quotes рдЖрд╡рд╢реНрдпрдХ рдЖрд╣реЗрдд: рддреЗ value рдард░рд▓реЗрд▓реА (fixed) рдЖрд╣реЗ, variable рдирд╛рд╣реА рд╣реЗ рджрд╛рдЦрд╡рддрд╛рдд.)

рдЖрдгрд┐ Lambda proxy back end рд╕реНрд╡рддрдГрдЪреНрдпрд╛ рдЙрддреНрддрд░рд╛рдВрдордзреНрдпреЗ header рдШрд╛рд▓рддреЛ:

return {"statusCode": 200,
        "headers": {"Access-Control-Allow-Origin": "https://school.example"},
        "body": json.dumps(student)}

ЁЯПн Production рдордзреНрдпреЗ рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ рдЖрд╣реЗ: рдкреНрд░рддреНрдпреЗрдХ stage рд╕рд╛рдареА рдиреЗрдордХреЗ origins рдпрд╛рджреАрдд рд▓рд┐рд╣рд╛ (dev web site рдЖрдгрд┐ prod web site рд╡реЗрдЧрд│реНрдпрд╛ рдЖрд╣реЗрдд), errors рд╡рд░рд╣реА headers рдШрд╛рд▓рд╛, рдЖрдгрд┐ CORS рд▓рд╛ рдХрдзреАрд╣реА authorizer рдЪрд╛ рдкрд░реНрдпрд╛рдп рдорд╛рдиреВ рдирдХрд╛.

тПня╕П рдкреБрдвреЗ

Browsers рдЦреВрд╢ рдЖрд╣реЗрдд. рдкрдг рдкрддреНрддрд╛ рдЕрдЬреВрдирд╣реА abc123.execute-api.ap-south-1.amazonaws.com рдЖрд╣реЗ. Office рд▓рд╛ рдирд╛рд╡рдлрд▓рдХ рджреНрдпрд╛ тАФ custom domains рдЖрдгрд┐ TLS.

git checkout lesson-10-custom-domains

ЁЯМР Lesson 09 тАФ CORS: the browser asks first

ЁЯУН You are here: Lesson 09 of 12 ┬╖ Previous: lesson-08-caching ┬╖ Next: lesson-10-custom-domains


ЁЯУж What's in this branch

Lessons 01тАУ08, plus CORS: the preflight OPTIONS request a browser sends first, the Access-Control-Allow-* headers the office answers with, and why CORS protects browsers, not your API. cors() in apigw/demo.py answers a preflight from the school's own web site and from a stranger's.

ЁЯзТ Explain like I'm 5

A parent's browser ЁЯМР is a very careful helper. The parent is reading the school's web page, https://school.example. That page asks the browser: "please fetch Aishwarya's marks from https://api.school.example". That is another address.

So the browser first knocks on the office door and asks: "The page from school.example wants to send a GET. Is that all right?" That knock is the preflight (OPTIONS).

The clerk answers: "Yes тАФ for school.example, these methods, these headers, and you may remember this answer for 10 minutes." Then the browser sends the real request.

A page from evil.example asks the same. The clerk says no. The browser then refuses to give that page the answer.

Notice: it is the browser that obeys. A person using curl never asks first тАФ the clerk's "no" does not stop them.

ЁЯЧ║я╕П Diagram

sequenceDiagram
    participant P as ЁЯУД page on school.example
    participant B as ЁЯМР browser
    participant O as ЁЯЫОя╕П front office
    P->>B: fetch api/students/7
    B->>O: OPTIONS /students/7 ┬╖ Origin: https://school.example
    O-->>B: 204 ┬╖ Allow-Origin: https://school.example ┬╖ Max-Age: 600
    B->>O: GET /students/7 ┬╖ Origin: https://school.example
    O-->>B: 200 ┬╖ Access-Control-Allow-Origin: https://school.example
    B-->>P: the answer
    Note over B,O: Origin https://evil.example тЖТ 403, and the browser blocks the page

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/apigateway/lesson-diagrams.html#l09

тЭУ What

ЁЯдФ Why

Because almost every web app calls an API on another origin, and a missing header is the most common "it works in curl but not in the browser" bug. And because many people think CORS is a security wall for the API. It is not: it protects the user's browser from pages that try to read data with the user's cookies. Your API still needs auth (lesson 06).

ЁЯФз How (in this repo)

Gateway._handle() in apigw/gateway.py answers every OPTIONS itself, before route matching (like an HTTP API's CORS setting): an origin in cors_origins gets 204 with the four Access-Control-* headers; any other origin gets 403 CORS origin not allowed. Gateway.handle() also adds Access-Control-Allow-Origin to the real answer when the request's Origin is allowed. office() allows exactly one origin: https://school.example.

ЁЯзк Try it

python3 apigw/demo.py cors
python3 - <<'EOF'
import sys; sys.path.insert(0, "apigw"); from demo import office
gw, _ = office()
for origin in ("https://school.example", "http://school.example", "https://school.example.evil.com"):
    st, hd, _ = gw.handle("OPTIONS", "/marks", {"Origin": origin, "Access-Control-Request-Method": "POST"})
    print(st, origin, hd.get("Access-Control-Allow-Origin"))
st, hd, out = gw.handle("GET", "/students/9", {"Origin": "https://evil.example"})
print("GET from evil.example:", st, out, "┬╖ Allow-Origin header:", hd.get("Access-Control-Allow-Origin"))
EOF

Be the browser yourself, on the live office:

python3 apigw/demo.py serve &
sleep 1                                # give the office a second to open
curl -i -X OPTIONS -H "Origin: https://school.example" -H "Access-Control-Request-Method: GET" localhost:8080/students/7
curl -i -X OPTIONS -H "Origin: https://evil.example" localhost:8080/students/7
curl -i -H "Origin: https://evil.example" localhost:8080/students/9
kill %1

тЬЕ Verify тАФ what you should see

cors prints OPTIONS /students/7 тЖТ 204 Access-Control-Allow-Origin: https://school.example Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: authorization,content-type,x-api-key Access-Control-Max-Age: 600, then OPTIONS /students/7 тЖТ 403 {'message': 'CORS origin not allowed'} for https://evil.example, then the real GET with X-Cache: Miss Access-Control-Allow-Origin: https://school.example.

Your snippet prints 204 https://school.example https://school.example, 403 http://school.example None (a different scheme is a different origin) and 403 https://school.example.evil.com None (a look-alike name is not the same origin). The last line is the important one: GET from evil.example: 200 {'id': '9', 'name': 'Katrina', 'class': '9A'} ┬╖ Allow-Origin header: None тАФ the office did answer; only a browser would hide the answer from the page.

On the live office: HTTP/1.0 204 No Content with the four Access-Control-* headers; HTTP/1.0 403 Forbidden with {"message": "CORS origin not allowed"}; and for the plain GET from evil.example, HTTP/1.0 200 OK with the data and no Access-Control-Allow-Origin header.

ЁЯПБ What you just proved

You can read a preflight like a browser does, and you saw with your own eyes that CORS does not stop curl тАФ it is a rule browsers follow, not a lock on the API.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ CORS on an HTTP API is one setting:

aws apigatewayv2 update-api --api-id a1b2c3 --cors-configuration \
    AllowOrigins=https://school.example,AllowMethods=GET,POST,AllowHeaders=authorization,content-type,MaxAge=600

On a REST API, let browser pages read the office's own errors too:

cat > cors-4xx.json <<'EOF'
{ "gatewayresponse.header.Access-Control-Allow-Origin": "'https://school.example'" }
EOF
aws apigateway put-gateway-response --rest-api-id abc123 --response-type DEFAULT_4XX \
    --response-parameters file://cors-4xx.json

(The inner single quotes are required: they mark a fixed value, not a variable.)

And a Lambda proxy back end adds the header to its own answers:

return {"statusCode": 200,
        "headers": {"Access-Control-Allow-Origin": "https://school.example"},
        "body": json.dumps(student)}

ЁЯПн Why this matters in production: list exact origins per stage (the dev web site and the prod web site are different), include the headers on errors, and never treat CORS as a replacement for an authorizer.

тПня╕П Next

Browsers are happy. But the address is still abc123.execute-api.ap-south-1.amazonaws.com. Give the office a name plate тАФ custom domains and TLS.

git checkout lesson-10-custom-domains
тЖР PreviouscachingNext тЖТcustom domains

This page is the lesson's README from the lesson-09-cors branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.