[{"data":1,"prerenderedAt":2084},["ShallowReactive",2],{"page-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis\u002F":3,"faq-schema-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis\u002F":2063},{"id":4,"title":5,"body":6,"description":2052,"extension":2053,"meta":2054,"navigation":486,"path":2059,"seo":2060,"stem":2061,"__hash__":2062},"content\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis\u002Findex.md","JWT vs API Keys for Python APIs",{"type":7,"value":8,"toc":2041},"minimark",[9,13,23,26,31,44,51,54,174,176,180,183,302,412,414,418,428,750,765,768,957,964,967,1231,1239,1309,1316,1318,1322,1325,1343,1354,1364,1377,1438,1445,1447,1451,1459,1477,1542,1544,1548,1559,1784,1797,1927,1929,1933,1945,1947,1951,1957,1963,1969,1979,1985,1987,1991,1996,2024,2029,2037],[10,11,5],"h1",{"id":12},"jwt-vs-api-keys-for-python-apis",[14,15,16,17,22],"p",{},"Every Python API has to answer one question before its first paying customer: how do callers prove who they are? The two credible answers are signed JWTs and stored API keys, and the choice shapes your revocation story, your billing model, your p99 latency and how much state you carry. This page compares them head to head, prices both verification paths in milliseconds and dollars, and gives you runnable code for each. It is part of the ",[18,19,21],"a",{"href":20},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","Handling API Authentication in Python"," guide, which has the full implementation; here we only resolve the decision.",[24,25],"hr",{},[27,28,30],"h2",{"id":29},"how-each-credential-earns-trust","How each credential earns trust",[14,32,33,34,38,39,43],{},"The two formats differ in one place that matters: where the proof lives. A JWT carries its own proof. Three base64url segments — a header naming the algorithm and key id, a payload of claims, and a signature over both — travel in the ",[35,36,37],"code",{},"Authorization"," header, and your server decides the token is genuine by recomputing the signature with a secret only it holds. Nothing is looked up. The token ",[40,41,42],"em",{},"is"," the record.",[14,45,46,47,50],{},"An API key carries no proof at all. It is a high-entropy random string that means nothing until it matches something you stored. Give it structure — a ",[35,48,49],{},"pk_live"," prefix so scanners can spot it in a public repository, a public key id so you can index the lookup, and a secret segment you only ever store hashed — and verification becomes a row fetch plus a constant-time comparison.",[14,52,53],{},"That difference cascades into everything else. Because a JWT needs no store, every replica verifies it independently and horizontal scaling costs nothing. Because an API key needs a store, you can delete the row and kill access on the very next request. You cannot have both properties from one credential, which is why almost every commercial API eventually ships both.",[55,56,64,65,64,69,64,73,64,80,64,88,64,97,64,103,64,109,64,113,64,117,64,120,64,124,64,128,64,133,64,140,64,144,64,148,64,153,64,157,64,161,64,166,64,170],"svg",{"viewBox":57,"role":58,"ariaLabelledBy":59,"xmlns":62,"style":63},"0 0 720 300","img",[60,61],"jwtkey-anat-t","jwtkey-anat-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[66,67,68],"title",{"id":60},"Anatomy of a JWT compared with a structured API key",[70,71,72],"desc",{"id":61},"A JWT split into three segments — header, payload and signature — captioned as verified locally with no lookups, shown above an API key split into a live prefix, a public key id and a secret, captioned as verified by hashing the secret and comparing it to a stored row.",[74,75],"rect",{"x":76,"y":76,"width":77,"height":78,"fill":79},"0","720","300","var(--c-surface)",[81,82,87],"text",{"x":83,"y":84,"fill":85,"style":86},"36","40","var(--c-text)","font-size:14;font-family:var(--font-sans);","JWT — the proof travels inside the token",[74,89],{"x":83,"y":90,"width":91,"height":92,"rx":93,"fill":94,"stroke":95,"style":96},"56","180","46","8","var(--c-surface-alt)","var(--c-blue)","stroke-width:2;",[81,98,102],{"x":99,"y":100,"fill":85,"style":101},"126","84","text-anchor:middle;font-size:12;font-family:var(--font-sans);","header: alg, kid",[81,104,108],{"x":105,"y":100,"fill":106,"style":107},"220","var(--c-text-muted)","text-anchor:middle;font-size:14;font-family:var(--font-sans);",".",[74,110],{"x":111,"y":90,"width":112,"height":92,"rx":93,"fill":94,"stroke":95,"style":96},"224","240",[81,114,116],{"x":115,"y":100,"fill":85,"style":101},"344","payload: sub, exp, scopes",[81,118,108],{"x":119,"y":100,"fill":106,"style":107},"468",[74,121],{"x":122,"y":90,"width":123,"height":92,"rx":93,"fill":94,"stroke":95,"style":96},"472","212",[81,125,127],{"x":126,"y":100,"fill":85,"style":101},"578","signature: HMAC-SHA256",[81,129,132],{"x":83,"y":130,"fill":106,"style":131},"128","font-size:12;font-family:var(--font-sans);","Verified locally: recompute the signature, check exp. Zero lookups.",[134,135],"line",{"x1":83,"y1":136,"x2":137,"y2":136,"stroke":138,"style":139},"150","684","var(--c-border)","stroke-width:1;",[81,141,143],{"x":83,"y":142,"fill":85,"style":86},"182","API key — the proof lives in your database",[74,145],{"x":83,"y":146,"width":136,"height":92,"rx":93,"fill":94,"stroke":147,"style":96},"198","var(--c-teal)",[81,149,152],{"x":150,"y":151,"fill":85,"style":101},"111","226","prefix: pk_live",[74,154],{"x":155,"y":146,"width":156,"height":92,"rx":93,"fill":94,"stroke":147,"style":96},"194","190",[81,158,160],{"x":159,"y":151,"fill":85,"style":101},"289","key id (public)",[74,162],{"x":163,"y":146,"width":164,"height":92,"rx":93,"fill":94,"stroke":165,"style":96},"392","292","var(--c-coral)",[81,167,169],{"x":168,"y":151,"fill":85,"style":101},"538","secret: 32 random bytes",[81,171,173],{"x":83,"y":172,"fill":106,"style":131},"270","Verified by lookup: hash the secret, compare it to the stored row.",[24,175],{},[27,177,179],{"id":178},"side-by-side-comparison","Side-by-side comparison",[14,181,182],{},"Read the table below as a list of consequences, not features. Every row is downstream of that one structural difference: signature versus stored row.",[184,185,186,202],"table",{},[187,188,189],"thead",{},[190,191,192,196,199],"tr",{},[193,194,195],"th",{},"Dimension",[193,197,198],{},"JWT",[193,200,201],{},"API key",[203,204,205,220,233,246,263,276,289],"tbody",{},[190,206,207,214,217],{},[208,209,210],"td",{},[211,212,213],"strong",{},"Verification",[208,215,216],{},"Local signature check, no DB",[208,218,219],{},"Hash lookup against a store",[190,221,222,227,230],{},[208,223,224],{},[211,225,226],{},"Revocation",[208,228,229],{},"Hard — needs a denylist or short TTL",[208,231,232],{},"Instant — delete the row",[190,234,235,240,243],{},[208,236,237],{},[211,238,239],{},"Statelessness",[208,241,242],{},"Fully stateless",[208,244,245],{},"Requires a store",[190,247,248,253,260],{},[208,249,250],{},[211,251,252],{},"Expiry",[208,254,255,256,259],{},"Native ",[35,257,258],{},"exp"," claim",[208,261,262],{},"Manual; many keys never expire",[190,264,265,270,273],{},[208,266,267],{},[211,268,269],{},"Rotation",[208,271,272],{},"Rotate the signing secret",[208,274,275],{},"Regenerate each key independently",[190,277,278,283,286],{},[208,279,280],{},[211,281,282],{},"Carries claims",[208,284,285],{},"Yes — user id, scopes, tier inline",[208,287,288],{},"No — you look up metadata",[190,290,291,296,299],{},[208,292,293],{},[211,294,295],{},"Best for",[208,297,298],{},"User sessions, scoped access, SPAs",[208,300,301],{},"Server-to-server, metered billing, CLIs",[55,303,64,308,64,311,64,314,64,317,64,321,64,327,64,330,64,333,64,337,64,340,64,344,64,348,64,352,64,356,64,359,64,362,64,365,64,368,64,371,64,373,64,376,64,379,64,382,64,385,64,388,64,391,64,394,64,397,64,401,64,404,64,406,64,408,64,410],{"viewBox":304,"role":58,"ariaLabelledBy":305,"xmlns":62,"style":63},"0 0 720 320",[306,307],"jwtkey-mx-t","jwtkey-mx-d",[66,309,310],{"id":306},"JWT versus API keys comparison matrix",[70,312,313],{"id":307},"A matrix comparing JWTs and API keys across revocation, state, expiry, rotation and best fit, showing JWTs favour stateless user sessions and API keys favour revocable server-to-server access.",[74,315],{"x":76,"y":76,"width":77,"height":316,"fill":79},"320",[74,318],{"x":319,"y":319,"width":105,"height":84,"rx":93,"fill":79,"stroke":138,"style":320},"20","stroke-width:1.5;",[81,322,326],{"x":323,"y":324,"fill":106,"style":325},"130","45","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Property",[74,328],{"x":329,"y":319,"width":105,"height":84,"rx":93,"fill":79,"stroke":95,"style":96},"245",[81,331,198],{"x":332,"y":324,"fill":85,"style":107},"355",[74,334],{"x":335,"y":319,"width":336,"height":84,"rx":93,"fill":79,"stroke":147,"style":96},"470","230",[81,338,201],{"x":339,"y":324,"fill":85,"style":107},"585",[81,341,226],{"x":342,"y":343,"fill":106,"style":131},"30","92",[81,345,347],{"x":346,"y":343,"fill":85,"style":131},"255","Hard (denylist)",[81,349,351],{"x":350,"y":343,"fill":85,"style":131},"480","Instant (DB)",[81,353,355],{"x":342,"y":354,"fill":106,"style":131},"132","State",[81,357,358],{"x":346,"y":354,"fill":85,"style":131},"Stateless",[81,360,361],{"x":350,"y":354,"fill":85,"style":131},"Stored lookup",[81,363,252],{"x":342,"y":364,"fill":106,"style":131},"172",[81,366,367],{"x":346,"y":364,"fill":85,"style":131},"Built-in (exp)",[81,369,370],{"x":350,"y":364,"fill":85,"style":131},"Manual \u002F none",[81,372,269],{"x":342,"y":123,"fill":106,"style":131},[81,374,375],{"x":346,"y":123,"fill":85,"style":131},"Rotate signing key",[81,377,378],{"x":350,"y":123,"fill":85,"style":131},"Regenerate per key",[81,380,295],{"x":342,"y":381,"fill":106,"style":131},"252",[81,383,384],{"x":346,"y":381,"fill":85,"style":131},"User sessions",[81,386,387],{"x":350,"y":381,"fill":85,"style":131},"Server-to-server",[81,389,390],{"x":342,"y":164,"fill":106,"style":131},"Verify cost",[81,392,393],{"x":346,"y":164,"fill":85,"style":131},"~0.02 ms, no I\u002FO",[81,395,396],{"x":350,"y":164,"fill":85,"style":131},"0.4-1.6 ms + I\u002FO",[134,398],{"x1":319,"y1":399,"x2":400,"y2":399,"stroke":138,"style":139},"70","700",[134,402],{"x1":319,"y1":403,"x2":400,"y2":403,"stroke":138,"style":139},"110",[134,405],{"x1":319,"y1":136,"x2":400,"y2":136,"stroke":138,"style":139},[134,407],{"x1":319,"y1":156,"x2":400,"y2":156,"stroke":138,"style":139},[134,409],{"x1":319,"y1":336,"x2":400,"y2":336,"stroke":138,"style":139},[134,411],{"x1":319,"y1":172,"x2":400,"y2":172,"stroke":138,"style":139},[24,413],{},[27,415,417],{"id":416},"both-verification-paths-and-what-each-one-costs","Both verification paths, and what each one costs",[14,419,420,421,423,424,427],{},"Here is the JWT path. Trust comes entirely from the signature and ",[35,422,258],{}," — note the algorithm is pinned, which is what stops the ",[35,425,426],{},"alg=none"," forgery attack.",[429,430,435],"pre",{"className":431,"code":432,"language":433,"meta":434,"style":434},"language-python shiki shiki-themes github-light github-dark","# verify_jwt.py\nimport os\nimport jwt  # PyJWT\nfrom fastapi import HTTPException, status\n\nJWT_SECRET = os.getenv(\"JWT_SECRET\", \"\")\nJWT_ALGORITHM = os.getenv(\"JWT_ALGORITHM\", \"HS256\")\nJWT_AUDIENCE = os.getenv(\"JWT_AUDIENCE\", \"api\")\nJWT_LEEWAY_SECONDS = int(os.getenv(\"JWT_LEEWAY_SECONDS\", \"10\"))\n\n\ndef verify_jwt(token: str) -> dict:\n    try:\n        return jwt.decode(\n            token,\n            JWT_SECRET,\n            algorithms=[JWT_ALGORITHM],\n            audience=JWT_AUDIENCE,\n            leeway=JWT_LEEWAY_SECONDS,\n            options={\"require\": [\"exp\", \"sub\"]},\n        )\n    except jwt.InvalidTokenError:\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid or expired token\")\n","python","",[35,436,437,445,456,467,481,488,514,534,554,579,584,589,614,622,631,637,646,664,676,688,716,722,731],{"__ignoreMap":434},[438,439,441],"span",{"class":134,"line":440},1,[438,442,444],{"class":443},"sJ8bj","# verify_jwt.py\n",[438,446,448,452],{"class":134,"line":447},2,[438,449,451],{"class":450},"szBVR","import",[438,453,455],{"class":454},"sVt8B"," os\n",[438,457,459,461,464],{"class":134,"line":458},3,[438,460,451],{"class":450},[438,462,463],{"class":454}," jwt  ",[438,465,466],{"class":443},"# PyJWT\n",[438,468,470,473,476,478],{"class":134,"line":469},4,[438,471,472],{"class":450},"from",[438,474,475],{"class":454}," fastapi ",[438,477,451],{"class":450},[438,479,480],{"class":454}," HTTPException, status\n",[438,482,484],{"class":134,"line":483},5,[438,485,487],{"emptyLinePlaceholder":486},true,"\n",[438,489,491,495,498,501,505,508,511],{"class":134,"line":490},6,[438,492,494],{"class":493},"sj4cs","JWT_SECRET",[438,496,497],{"class":450}," =",[438,499,500],{"class":454}," os.getenv(",[438,502,504],{"class":503},"sZZnC","\"JWT_SECRET\"",[438,506,507],{"class":454},", ",[438,509,510],{"class":503},"\"\"",[438,512,513],{"class":454},")\n",[438,515,517,520,522,524,527,529,532],{"class":134,"line":516},7,[438,518,519],{"class":493},"JWT_ALGORITHM",[438,521,497],{"class":450},[438,523,500],{"class":454},[438,525,526],{"class":503},"\"JWT_ALGORITHM\"",[438,528,507],{"class":454},[438,530,531],{"class":503},"\"HS256\"",[438,533,513],{"class":454},[438,535,537,540,542,544,547,549,552],{"class":134,"line":536},8,[438,538,539],{"class":493},"JWT_AUDIENCE",[438,541,497],{"class":450},[438,543,500],{"class":454},[438,545,546],{"class":503},"\"JWT_AUDIENCE\"",[438,548,507],{"class":454},[438,550,551],{"class":503},"\"api\"",[438,553,513],{"class":454},[438,555,557,560,562,565,568,571,573,576],{"class":134,"line":556},9,[438,558,559],{"class":493},"JWT_LEEWAY_SECONDS",[438,561,497],{"class":450},[438,563,564],{"class":493}," int",[438,566,567],{"class":454},"(os.getenv(",[438,569,570],{"class":503},"\"JWT_LEEWAY_SECONDS\"",[438,572,507],{"class":454},[438,574,575],{"class":503},"\"10\"",[438,577,578],{"class":454},"))\n",[438,580,582],{"class":134,"line":581},10,[438,583,487],{"emptyLinePlaceholder":486},[438,585,587],{"class":134,"line":586},11,[438,588,487],{"emptyLinePlaceholder":486},[438,590,592,595,599,602,605,608,611],{"class":134,"line":591},12,[438,593,594],{"class":450},"def",[438,596,598],{"class":597},"sScJk"," verify_jwt",[438,600,601],{"class":454},"(token: ",[438,603,604],{"class":493},"str",[438,606,607],{"class":454},") -> ",[438,609,610],{"class":493},"dict",[438,612,613],{"class":454},":\n",[438,615,617,620],{"class":134,"line":616},13,[438,618,619],{"class":450},"    try",[438,621,613],{"class":454},[438,623,625,628],{"class":134,"line":624},14,[438,626,627],{"class":450},"        return",[438,629,630],{"class":454}," jwt.decode(\n",[438,632,634],{"class":134,"line":633},15,[438,635,636],{"class":454},"            token,\n",[438,638,640,643],{"class":134,"line":639},16,[438,641,642],{"class":493},"            JWT_SECRET",[438,644,645],{"class":454},",\n",[438,647,649,653,656,659,661],{"class":134,"line":648},17,[438,650,652],{"class":651},"s4XuR","            algorithms",[438,654,655],{"class":450},"=",[438,657,658],{"class":454},"[",[438,660,519],{"class":493},[438,662,663],{"class":454},"],\n",[438,665,667,670,672,674],{"class":134,"line":666},18,[438,668,669],{"class":651},"            audience",[438,671,655],{"class":450},[438,673,539],{"class":493},[438,675,645],{"class":454},[438,677,679,682,684,686],{"class":134,"line":678},19,[438,680,681],{"class":651},"            leeway",[438,683,655],{"class":450},[438,685,559],{"class":493},[438,687,645],{"class":454},[438,689,691,694,696,699,702,705,708,710,713],{"class":134,"line":690},20,[438,692,693],{"class":651},"            options",[438,695,655],{"class":450},[438,697,698],{"class":454},"{",[438,700,701],{"class":503},"\"require\"",[438,703,704],{"class":454},": [",[438,706,707],{"class":503},"\"exp\"",[438,709,507],{"class":454},[438,711,712],{"class":503},"\"sub\"",[438,714,715],{"class":454},"]},\n",[438,717,719],{"class":134,"line":718},21,[438,720,721],{"class":454},"        )\n",[438,723,725,728],{"class":134,"line":724},22,[438,726,727],{"class":450},"    except",[438,729,730],{"class":454}," jwt.InvalidTokenError:\n",[438,732,734,737,740,743,745,748],{"class":134,"line":733},23,[438,735,736],{"class":450},"        raise",[438,738,739],{"class":454}," HTTPException(status.",[438,741,742],{"class":493},"HTTP_401_UNAUTHORIZED",[438,744,507],{"class":454},[438,746,747],{"class":503},"\"Invalid or expired token\"",[438,749,513],{"class":454},[14,751,752,753,756,757,760,761,764],{},"Three details there are load-bearing in production. ",[35,754,755],{},"algorithms=[JWT_ALGORITHM]"," is a whitelist, not a hint. ",[35,758,759],{},"audience"," stops a token minted for your billing service being replayed against your public API. ",[35,762,763],{},"leeway"," absorbs the second or two of clock drift between a container and your token issuer, which otherwise produces intermittent 401s that look impossible to reproduce.",[14,766,767],{},"And the API-key path. Trust comes from a hash match against the store; you never store the raw key, and the comparison runs in constant time so response timing cannot leak the secret.",[429,769,771],{"className":431,"code":770,"language":433,"meta":434,"style":434},"# verify_apikey.py\nimport os\nfrom passlib.hash import bcrypt\nfrom fastapi import HTTPException, status\n\nAPI_KEY_SALT = os.getenv(\"API_KEY_SALT\", \"\")\n\n# In production: a DB row indexed by a non-secret key id.\nKEY_STORE: dict[str, str] = {}  # {key_id: bcrypt_hash}\n\n\ndef verify_api_key(key_id: str, raw_key: str) -> str:\n    hashed = KEY_STORE.get(key_id)\n    if not hashed or not bcrypt.verify(raw_key + API_KEY_SALT, hashed):\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid API key\")\n    return key_id\n",[35,772,773,778,784,796,806,810,828,832,837,862,866,870,893,906,934,949],{"__ignoreMap":434},[438,774,775],{"class":134,"line":440},[438,776,777],{"class":443},"# verify_apikey.py\n",[438,779,780,782],{"class":134,"line":447},[438,781,451],{"class":450},[438,783,455],{"class":454},[438,785,786,788,791,793],{"class":134,"line":458},[438,787,472],{"class":450},[438,789,790],{"class":454}," passlib.hash ",[438,792,451],{"class":450},[438,794,795],{"class":454}," bcrypt\n",[438,797,798,800,802,804],{"class":134,"line":469},[438,799,472],{"class":450},[438,801,475],{"class":454},[438,803,451],{"class":450},[438,805,480],{"class":454},[438,807,808],{"class":134,"line":483},[438,809,487],{"emptyLinePlaceholder":486},[438,811,812,815,817,819,822,824,826],{"class":134,"line":490},[438,813,814],{"class":493},"API_KEY_SALT",[438,816,497],{"class":450},[438,818,500],{"class":454},[438,820,821],{"class":503},"\"API_KEY_SALT\"",[438,823,507],{"class":454},[438,825,510],{"class":503},[438,827,513],{"class":454},[438,829,830],{"class":134,"line":516},[438,831,487],{"emptyLinePlaceholder":486},[438,833,834],{"class":134,"line":536},[438,835,836],{"class":443},"# In production: a DB row indexed by a non-secret key id.\n",[438,838,839,842,845,847,849,851,854,856,859],{"class":134,"line":556},[438,840,841],{"class":493},"KEY_STORE",[438,843,844],{"class":454},": dict[",[438,846,604],{"class":493},[438,848,507],{"class":454},[438,850,604],{"class":493},[438,852,853],{"class":454},"] ",[438,855,655],{"class":450},[438,857,858],{"class":454}," {}  ",[438,860,861],{"class":443},"# {key_id: bcrypt_hash}\n",[438,863,864],{"class":134,"line":581},[438,865,487],{"emptyLinePlaceholder":486},[438,867,868],{"class":134,"line":586},[438,869,487],{"emptyLinePlaceholder":486},[438,871,872,874,877,880,882,885,887,889,891],{"class":134,"line":591},[438,873,594],{"class":450},[438,875,876],{"class":597}," verify_api_key",[438,878,879],{"class":454},"(key_id: ",[438,881,604],{"class":493},[438,883,884],{"class":454},", raw_key: ",[438,886,604],{"class":493},[438,888,607],{"class":454},[438,890,604],{"class":493},[438,892,613],{"class":454},[438,894,895,898,900,903],{"class":134,"line":616},[438,896,897],{"class":454},"    hashed ",[438,899,655],{"class":450},[438,901,902],{"class":493}," KEY_STORE",[438,904,905],{"class":454},".get(key_id)\n",[438,907,908,911,914,917,920,922,925,928,931],{"class":134,"line":624},[438,909,910],{"class":450},"    if",[438,912,913],{"class":450}," not",[438,915,916],{"class":454}," hashed ",[438,918,919],{"class":450},"or",[438,921,913],{"class":450},[438,923,924],{"class":454}," bcrypt.verify(raw_key ",[438,926,927],{"class":450},"+",[438,929,930],{"class":493}," API_KEY_SALT",[438,932,933],{"class":454},", hashed):\n",[438,935,936,938,940,942,944,947],{"class":134,"line":633},[438,937,736],{"class":450},[438,939,739],{"class":454},[438,941,742],{"class":493},[438,943,507],{"class":454},[438,945,946],{"class":503},"\"Invalid API key\"",[438,948,513],{"class":454},[438,950,951,954],{"class":134,"line":639},[438,952,953],{"class":450},"    return",[438,955,956],{"class":454}," key_id\n",[14,958,959,960,963],{},"That version is correct and it is what most tutorials show, but bcrypt on the hot path will hurt you. bcrypt is deliberately slow because passwords are low-entropy and need to resist offline cracking. An API key you generated with ",[35,961,962],{},"secrets.token_urlsafe(32)"," carries 256 bits of entropy — nobody is brute-forcing it, ever. At the default cost factor of 12 you are paying roughly 250 ms of pure CPU on every single request to defend against an attack that cannot happen, and on a 1 vCPU instance that caps you near four authenticated requests per second.",[14,965,966],{},"Swap it for a keyed HMAC. The pepper lives in your environment, so a stolen database dump still yields nothing, and verification drops into the microsecond range:",[429,968,970],{"className":431,"code":969,"language":433,"meta":434,"style":434},"# verify_apikey_fast.py\nimport hmac\nimport os\nfrom hashlib import sha256\n\nfrom fastapi import HTTPException, status\n\nAPI_KEY_PEPPER = os.getenv(\"API_KEY_PEPPER\", \"\").encode()\n\n\ndef fingerprint(raw_secret: str) -> str:\n    return hmac.new(API_KEY_PEPPER, raw_secret.encode(), sha256).hexdigest()\n\n\nasync def verify_api_key(key_id: str, raw_secret: str, store) -> str:\n    row = await store.fetch_key(key_id)  # indexed lookup, returns None or a row\n    match row:\n        case None:\n            raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid API key\")\n        case {\"fingerprint\": stored, \"revoked_at\": None} if hmac.compare_digest(\n            stored, fingerprint(raw_secret)\n        ):\n            return key_id\n        case _:\n            raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid API key\")\n",[35,971,972,977,984,990,1002,1006,1016,1020,1039,1043,1047,1065,1077,1081,1085,1111,1127,1135,1145,1160,1191,1196,1201,1208,1216],{"__ignoreMap":434},[438,973,974],{"class":134,"line":440},[438,975,976],{"class":443},"# verify_apikey_fast.py\n",[438,978,979,981],{"class":134,"line":447},[438,980,451],{"class":450},[438,982,983],{"class":454}," hmac\n",[438,985,986,988],{"class":134,"line":458},[438,987,451],{"class":450},[438,989,455],{"class":454},[438,991,992,994,997,999],{"class":134,"line":469},[438,993,472],{"class":450},[438,995,996],{"class":454}," hashlib ",[438,998,451],{"class":450},[438,1000,1001],{"class":454}," sha256\n",[438,1003,1004],{"class":134,"line":483},[438,1005,487],{"emptyLinePlaceholder":486},[438,1007,1008,1010,1012,1014],{"class":134,"line":490},[438,1009,472],{"class":450},[438,1011,475],{"class":454},[438,1013,451],{"class":450},[438,1015,480],{"class":454},[438,1017,1018],{"class":134,"line":516},[438,1019,487],{"emptyLinePlaceholder":486},[438,1021,1022,1025,1027,1029,1032,1034,1036],{"class":134,"line":536},[438,1023,1024],{"class":493},"API_KEY_PEPPER",[438,1026,497],{"class":450},[438,1028,500],{"class":454},[438,1030,1031],{"class":503},"\"API_KEY_PEPPER\"",[438,1033,507],{"class":454},[438,1035,510],{"class":503},[438,1037,1038],{"class":454},").encode()\n",[438,1040,1041],{"class":134,"line":556},[438,1042,487],{"emptyLinePlaceholder":486},[438,1044,1045],{"class":134,"line":581},[438,1046,487],{"emptyLinePlaceholder":486},[438,1048,1049,1051,1054,1057,1059,1061,1063],{"class":134,"line":586},[438,1050,594],{"class":450},[438,1052,1053],{"class":597}," fingerprint",[438,1055,1056],{"class":454},"(raw_secret: ",[438,1058,604],{"class":493},[438,1060,607],{"class":454},[438,1062,604],{"class":493},[438,1064,613],{"class":454},[438,1066,1067,1069,1072,1074],{"class":134,"line":591},[438,1068,953],{"class":450},[438,1070,1071],{"class":454}," hmac.new(",[438,1073,1024],{"class":493},[438,1075,1076],{"class":454},", raw_secret.encode(), sha256).hexdigest()\n",[438,1078,1079],{"class":134,"line":616},[438,1080,487],{"emptyLinePlaceholder":486},[438,1082,1083],{"class":134,"line":624},[438,1084,487],{"emptyLinePlaceholder":486},[438,1086,1087,1090,1093,1095,1097,1099,1102,1104,1107,1109],{"class":134,"line":633},[438,1088,1089],{"class":450},"async",[438,1091,1092],{"class":450}," def",[438,1094,876],{"class":597},[438,1096,879],{"class":454},[438,1098,604],{"class":493},[438,1100,1101],{"class":454},", raw_secret: ",[438,1103,604],{"class":493},[438,1105,1106],{"class":454},", store) -> ",[438,1108,604],{"class":493},[438,1110,613],{"class":454},[438,1112,1113,1116,1118,1121,1124],{"class":134,"line":639},[438,1114,1115],{"class":454},"    row ",[438,1117,655],{"class":450},[438,1119,1120],{"class":450}," await",[438,1122,1123],{"class":454}," store.fetch_key(key_id)  ",[438,1125,1126],{"class":443},"# indexed lookup, returns None or a row\n",[438,1128,1129,1132],{"class":134,"line":648},[438,1130,1131],{"class":450},"    match",[438,1133,1134],{"class":454}," row:\n",[438,1136,1137,1140,1143],{"class":134,"line":666},[438,1138,1139],{"class":450},"        case",[438,1141,1142],{"class":493}," None",[438,1144,613],{"class":454},[438,1146,1147,1150,1152,1154,1156,1158],{"class":134,"line":678},[438,1148,1149],{"class":450},"            raise",[438,1151,739],{"class":454},[438,1153,742],{"class":493},[438,1155,507],{"class":454},[438,1157,946],{"class":503},[438,1159,513],{"class":454},[438,1161,1162,1164,1167,1170,1173,1176,1179,1182,1185,1188],{"class":134,"line":690},[438,1163,1139],{"class":450},[438,1165,1166],{"class":454}," {",[438,1168,1169],{"class":503},"\"fingerprint\"",[438,1171,1172],{"class":454},": stored, ",[438,1174,1175],{"class":503},"\"revoked_at\"",[438,1177,1178],{"class":454},": ",[438,1180,1181],{"class":493},"None",[438,1183,1184],{"class":454},"} ",[438,1186,1187],{"class":450},"if",[438,1189,1190],{"class":454}," hmac.compare_digest(\n",[438,1192,1193],{"class":134,"line":718},[438,1194,1195],{"class":454},"            stored, fingerprint(raw_secret)\n",[438,1197,1198],{"class":134,"line":724},[438,1199,1200],{"class":454},"        ):\n",[438,1202,1203,1206],{"class":134,"line":733},[438,1204,1205],{"class":450},"            return",[438,1207,956],{"class":454},[438,1209,1211,1213],{"class":134,"line":1210},24,[438,1212,1139],{"class":450},[438,1214,1215],{"class":454}," _:\n",[438,1217,1219,1221,1223,1225,1227,1229],{"class":134,"line":1218},25,[438,1220,1149],{"class":450},[438,1222,739],{"class":454},[438,1224,742],{"class":493},[438,1226,507],{"class":454},[438,1228,946],{"class":503},[438,1230,513],{"class":454},[14,1232,1233,1234,1238],{},"Now the remaining cost is the lookup, not the hash — which means a Redis read-through cache in front of the key table turns a 1.6 ms Postgres round trip into 0.45 ms. The same trade-offs covered in ",[18,1235,1237],{"href":1236},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fredis-vs-in-memory-caching-for-fastapi\u002F","Redis vs in-memory caching for FastAPI"," apply directly here, with one extra rule: cap the cache TTL at 30 seconds so a revoked key cannot outlive its deletion by more than half a minute.",[55,1240,64,1244,64,1247,64,1250,64,1252,64,1256,64,1260,64,1265,64,1271,64,1274,64,1277,64,1281,64,1284,64,1287,64,1291,64,1294,64,1298,64,1302,64,1305],{"viewBox":57,"role":58,"ariaLabelledBy":1241,"xmlns":62,"style":63},[1242,1243],"jwtkey-cost-t","jwtkey-cost-d",[66,1245,1246],{"id":1242},"Median credential verification cost per request",[70,1248,1249],{"id":1243},"A logarithmic bar chart of median verification latency on one vCPU: an HS256 JWT decode at 0.02 milliseconds, an HMAC fingerprint with a Redis lookup at 0.45 milliseconds, an HMAC fingerprint with a Postgres index lookup at 1.6 milliseconds, and a bcrypt cost twelve verify at 250 milliseconds.",[74,1251],{"x":76,"y":76,"width":77,"height":78,"fill":79},[81,1253,1255],{"x":319,"y":1254,"fill":85,"style":86},"32","Median verify cost per request (log scale)",[134,1257],{"x1":91,"y1":1258,"x2":91,"y2":1259,"stroke":138,"style":320},"52","250",[74,1261],{"x":91,"y":1262,"width":342,"height":1263,"rx":1264,"fill":147},"62","28","4",[81,1266,1270],{"x":1267,"y":1268,"fill":85,"style":1269},"170","81","text-anchor:end;font-size:12;font-family:var(--font-sans);","HS256 JWT",[81,1272,1273],{"x":400,"y":1268,"fill":85,"style":1269},"0.02 ms",[74,1275],{"x":91,"y":403,"width":1276,"height":1263,"rx":1264,"fill":147},"165",[81,1278,1280],{"x":1267,"y":1279,"fill":85,"style":1269},"129","HMAC + Redis",[81,1282,1283],{"x":400,"y":1279,"fill":85,"style":1269},"0.45 ms",[74,1285],{"x":91,"y":1286,"width":105,"height":1263,"rx":1264,"fill":95},"158",[81,1288,1290],{"x":1267,"y":1289,"fill":85,"style":1269},"177","HMAC + Postgres",[81,1292,1293],{"x":400,"y":1289,"fill":85,"style":1269},"1.6 ms",[74,1295],{"x":91,"y":1296,"width":1297,"height":1263,"rx":1264,"fill":165},"206","440",[81,1299,1301],{"x":1267,"y":1300,"fill":85,"style":1269},"225","bcrypt cost 12",[81,1303,1304],{"x":400,"y":1300,"fill":85,"style":1269},"250 ms",[81,1306,1308],{"x":319,"y":1307,"fill":106,"style":131},"278","One vCPU. bcrypt costs 12,500x a JWT check for no added safety.",[14,1310,1311,1312,108],{},"The dollar version of that chart decides real architectures. At one million authenticated requests a month, JWT verification burns about 20 seconds of CPU in total — a rounding error on any plan. HMAC plus a cached lookup burns roughly 8 minutes of CPU plus a million Redis reads, still comfortably inside a $10 instance. bcrypt at cost 12 burns about 69 CPU-hours, which is a tenth of everything a $25 single-vCPU instance can do in a month, and it does so in 250 ms blocking chunks that wreck your p99. Fold those numbers into your own model using the method in ",[18,1313,1315],{"href":1314},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","calculating cost per API request",[24,1317],{},[27,1319,1321],{"id":1320},"failure-modes-that-cost-you-customers","Failure modes that cost you customers",[14,1323,1324],{},"Four mistakes account for most authentication incidents on small commercial APIs, and three of them are JWT-specific.",[14,1326,1327,1330,1331,1334,1335,1338,1339,1342],{},[211,1328,1329],{},"Unpinned algorithms."," Omit ",[35,1332,1333],{},"algorithms=[...]"," and a library may honour the token's own ",[35,1336,1337],{},"alg"," header, including ",[35,1340,1341],{},"none",". An attacker then writes their own claims. Pin it, and pin it to one value.",[14,1344,1345,1348,1349,1353],{},[211,1346,1347],{},"TTLs measured in days."," A 30-day access token is a 30-day breach. Keep access tokens at 5 to 15 minutes, hand out a refresh token for the sessions that outlive them, and accept that the ",[18,1350,1352],{"href":1351},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi\u002F","OAuth2 authorization code flow"," exists precisely to make that refresh loop safe.",[14,1355,1356,1359,1360,108],{},[211,1357,1358],{},"Credentials in URLs."," A key in a query string lands in access logs, CDN caches, referrer headers and browser history. Headers only — and scrub the header in your logging pipeline, which is trivial with the processor chain from ",[18,1361,1363],{"href":1362},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging with structlog",[14,1365,1366,1369,1370,1372,1373,1376],{},[211,1367,1368],{},"One shared secret across services."," If your worker, your dashboard and your public API all sign with the same ",[35,1371,494],{},", a leak in the least-guarded one forges tokens for all three. Use a ",[35,1374,1375],{},"kid"," header and separate signing keys per audience.",[55,1378,64,1383,64,1386,64,1389,64,1392,64,1397,64,1402,64,1405,64,1408,64,1411,64,1415,64,1417,64,1420,64,1423,64,1426,64,1429,64,1432,64,1435],{"viewBox":1379,"role":58,"ariaLabelledBy":1380,"xmlns":62,"style":63},"0 0 720 280",[1381,1382],"jwtkey-exp-t","jwtkey-exp-d",[66,1384,1385],{"id":1381},"Exposure window after a credential leak",[70,1387,1388],{"id":1382},"A timeline from leak to detection to revocation. A stolen API key stops working the moment the row is deleted, while a stolen JWT with a sixty minute time to live keeps working past revocation until its expiry claim passes.",[74,1390],{"x":76,"y":76,"width":77,"height":1391,"fill":79},"280",[81,1393,1396],{"x":1394,"y":1395,"fill":85,"style":131},"60","50","Stolen JWT, 60-minute TTL — keeps working past revocation",[74,1398],{"x":1399,"y":1394,"width":1400,"height":1401,"rx":1264,"fill":165},"100","460","22",[81,1403,1404],{"x":1394,"y":403,"fill":85,"style":131},"Stolen API key — dies the moment you delete the row",[74,1406],{"x":1399,"y":1407,"width":1391,"height":1401,"rx":1264,"fill":147},"120",[134,1409],{"x1":1394,"y1":1267,"x2":1410,"y2":1267,"stroke":138,"style":320},"680",[134,1412],{"x1":1399,"y1":1413,"x2":1399,"y2":1414,"stroke":106,"style":320},"162","178",[134,1416],{"x1":78,"y1":1413,"x2":78,"y2":1414,"stroke":106,"style":320},[134,1418],{"x1":1419,"y1":1413,"x2":1419,"y2":1414,"stroke":106,"style":320},"380",[134,1421],{"x1":1422,"y1":1413,"x2":1422,"y2":1414,"stroke":106,"style":320},"560",[81,1424,1425],{"x":1399,"y":146,"fill":85,"style":101},"leak",[81,1427,1428],{"x":78,"y":146,"fill":85,"style":101},"detected",[81,1430,1431],{"x":1419,"y":146,"fill":85,"style":101},"revoked",[81,1433,1434],{"x":1422,"y":146,"fill":85,"style":101},"token exp",[81,1436,1437],{"x":1394,"y":112,"fill":106,"style":131},"Every extra minute of TTL is a minute a stolen token still works.",[14,1439,1440,1441,108],{},"The API-key failure mode is quieter and more expensive: keys that never expire. A key issued in year one is still live in year three, sitting in an ex-contractor's laptop config. Fix that with a scheduled overlap window rather than a flag day — the mechanics are in ",[18,1442,1444],{"href":1443},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F","rotating API keys without downtime",[24,1446],{},[27,1448,1450],{"id":1449},"choosing-between-them","Choosing between them",[14,1452,1453,1454,1458],{},"Reach for API keys when the caller is another server, a cron job or a CLI holding one long-lived secret in its config; when you meter and bill per key, because a stable key id maps cleanly to a usage counter and a Stripe customer, which is exactly the pattern in ",[18,1455,1457],{"href":1456},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fhow-to-charge-for-api-access-using-stripe\u002F","how to charge for API access using Stripe","; when you need instant revocation; and when there is no user to log in at all. Avoid them for browser sessions, where a long-lived secret in front-end storage is a standing liability.",[14,1460,1461,1462,507,1465,1468,1469,1472,1473,1476],{},"Reach for JWTs when a human logs in, when routes need per-request scopes such as ",[35,1463,1464],{},"read",[35,1466,1467],{},"write"," and ",[35,1470,1471],{},"admin"," without a lookup, and when you want verification to scale across replicas for free. Avoid pure JWTs when you must forcibly log someone out ",[40,1474,1475],{},"right now"," and cannot tolerate even a few minutes of validity. Statelessness is the feature, and it is exactly what makes instant revocation hard.",[55,1478,64,1483,64,1486,64,1489,64,1492,64,1495,64,1500,64,1504,64,1507,64,1509,64,1511,64,1513,64,1517,64,1520,64,1523,64,1526,64,1528,64,1530,64,1533,64,1535,64,1538],{"viewBox":1479,"role":58,"ariaLabelledBy":1480,"xmlns":62,"style":63},"0 0 720 310",[1481,1482],"jwtkey-dec-t","jwtkey-dec-d",[66,1484,1485],{"id":1481},"Decision tree for choosing a credential type",[70,1487,1488],{"id":1482},"A decision tree starting from who holds the credential. Machine clients such as servers, cron jobs and command line tools lead to a hashed API key row that revokes instantly. Human sessions such as dashboards and single page apps lead to a short lived JWT backed by a token id denylist.",[74,1490],{"x":76,"y":76,"width":77,"height":1491,"fill":79},"310",[74,1493],{"x":1259,"y":319,"width":105,"height":1494,"rx":93,"fill":94,"stroke":138,"style":320},"44",[81,1496,1499],{"x":1497,"y":1498,"fill":85,"style":325},"360","48","Who holds the credential?",[134,1501],{"x1":1497,"y1":1502,"x2":1497,"y2":1503,"stroke":106,"style":320},"64","88",[134,1505],{"x1":91,"y1":1503,"x2":1506,"y2":1503,"stroke":106,"style":320},"540",[134,1508],{"x1":91,"y1":1503,"x2":91,"y2":403,"stroke":106,"style":320},[134,1510],{"x1":1506,"y1":1503,"x2":1506,"y2":403,"stroke":106,"style":320},[74,1512],{"x":84,"y":403,"width":1391,"height":1498,"rx":93,"fill":79,"stroke":147,"style":96},[81,1514,1516],{"x":91,"y":1515,"fill":85,"style":101},"140","Machine client: server, cron, CLI",[74,1518],{"x":1519,"y":403,"width":1391,"height":1498,"rx":93,"fill":79,"stroke":95,"style":96},"400",[81,1521,1522],{"x":1506,"y":1515,"fill":85,"style":101},"Human session: dashboard or SPA",[134,1524],{"x1":91,"y1":1286,"x2":91,"y2":1525,"stroke":106,"style":320},"200",[134,1527],{"x1":1506,"y1":1286,"x2":1506,"y2":1525,"stroke":106,"style":320},[74,1529],{"x":84,"y":1525,"width":1391,"height":1498,"rx":93,"fill":94,"stroke":147,"style":96},[81,1531,1532],{"x":91,"y":336,"fill":85,"style":101},"API key: hashed row, instant kill",[74,1534],{"x":1519,"y":1525,"width":1391,"height":1498,"rx":93,"fill":94,"stroke":95,"style":96},[81,1536,1537],{"x":1506,"y":336,"fill":85,"style":101},"Short-TTL JWT + jti denylist",[81,1539,1541],{"x":1497,"y":1540,"fill":106,"style":101},"282","Both branches land in one authenticate() dependency.",[24,1543],{},[27,1545,1547],{"id":1546},"migration-path","Migration path",[14,1549,1550,1551,1554,1555,1558],{},"Moving from API keys to JWTs — common as you add a user dashboard on top of a developer API — does not mean ripping anything out. Run both. Keep key auth on the ",[35,1552,1553],{},"\u002Fv1\u002F*"," machine routes, add ",[35,1556,1557],{},"\u002Fauth\u002Ftoken"," plus scope checks for the user-facing routes, and let one dependency try each in turn.",[429,1560,1562],{"className":431,"code":1561,"language":433,"meta":434,"style":434},"# hybrid.py\nfrom fastapi import HTTPException, Request, status\nfrom verify_jwt import verify_jwt\nfrom verify_apikey import verify_api_key\n\n\nasync def authenticate(request: Request) -> dict:\n    if key := request.headers.get(\"X-API-Key\"):\n        key_id, _, secret = key.partition(\".\")  # pk_\u003Cid>.\u003Csecret>\n        return {\"type\": \"key\", \"id\": verify_api_key(key_id, secret)}\n    auth = request.headers.get(\"Authorization\", \"\")\n    if auth.startswith(\"Bearer \"):\n        claims = verify_jwt(auth.removeprefix(\"Bearer \"))\n        return {\"type\": \"jwt\", \"id\": claims[\"sub\"], \"scopes\": claims.get(\"scopes\", [])}\n    raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"No credentials provided\")\n",[35,1563,1564,1569,1580,1592,1604,1608,1612,1628,1647,1666,1688,1706,1718,1732,1768],{"__ignoreMap":434},[438,1565,1566],{"class":134,"line":440},[438,1567,1568],{"class":443},"# hybrid.py\n",[438,1570,1571,1573,1575,1577],{"class":134,"line":447},[438,1572,472],{"class":450},[438,1574,475],{"class":454},[438,1576,451],{"class":450},[438,1578,1579],{"class":454}," HTTPException, Request, status\n",[438,1581,1582,1584,1587,1589],{"class":134,"line":458},[438,1583,472],{"class":450},[438,1585,1586],{"class":454}," verify_jwt ",[438,1588,451],{"class":450},[438,1590,1591],{"class":454}," verify_jwt\n",[438,1593,1594,1596,1599,1601],{"class":134,"line":469},[438,1595,472],{"class":450},[438,1597,1598],{"class":454}," verify_apikey ",[438,1600,451],{"class":450},[438,1602,1603],{"class":454}," verify_api_key\n",[438,1605,1606],{"class":134,"line":483},[438,1607,487],{"emptyLinePlaceholder":486},[438,1609,1610],{"class":134,"line":490},[438,1611,487],{"emptyLinePlaceholder":486},[438,1613,1614,1616,1618,1621,1624,1626],{"class":134,"line":516},[438,1615,1089],{"class":450},[438,1617,1092],{"class":450},[438,1619,1620],{"class":597}," authenticate",[438,1622,1623],{"class":454},"(request: Request) -> ",[438,1625,610],{"class":493},[438,1627,613],{"class":454},[438,1629,1630,1632,1635,1638,1641,1644],{"class":134,"line":536},[438,1631,910],{"class":450},[438,1633,1634],{"class":454}," key ",[438,1636,1637],{"class":450},":=",[438,1639,1640],{"class":454}," request.headers.get(",[438,1642,1643],{"class":503},"\"X-API-Key\"",[438,1645,1646],{"class":454},"):\n",[438,1648,1649,1652,1654,1657,1660,1663],{"class":134,"line":556},[438,1650,1651],{"class":454},"        key_id, _, secret ",[438,1653,655],{"class":450},[438,1655,1656],{"class":454}," key.partition(",[438,1658,1659],{"class":503},"\".\"",[438,1661,1662],{"class":454},")  ",[438,1664,1665],{"class":443},"# pk_\u003Cid>.\u003Csecret>\n",[438,1667,1668,1670,1672,1675,1677,1680,1682,1685],{"class":134,"line":581},[438,1669,627],{"class":450},[438,1671,1166],{"class":454},[438,1673,1674],{"class":503},"\"type\"",[438,1676,1178],{"class":454},[438,1678,1679],{"class":503},"\"key\"",[438,1681,507],{"class":454},[438,1683,1684],{"class":503},"\"id\"",[438,1686,1687],{"class":454},": verify_api_key(key_id, secret)}\n",[438,1689,1690,1693,1695,1697,1700,1702,1704],{"class":134,"line":586},[438,1691,1692],{"class":454},"    auth ",[438,1694,655],{"class":450},[438,1696,1640],{"class":454},[438,1698,1699],{"class":503},"\"Authorization\"",[438,1701,507],{"class":454},[438,1703,510],{"class":503},[438,1705,513],{"class":454},[438,1707,1708,1710,1713,1716],{"class":134,"line":591},[438,1709,910],{"class":450},[438,1711,1712],{"class":454}," auth.startswith(",[438,1714,1715],{"class":503},"\"Bearer \"",[438,1717,1646],{"class":454},[438,1719,1720,1723,1725,1728,1730],{"class":134,"line":616},[438,1721,1722],{"class":454},"        claims ",[438,1724,655],{"class":450},[438,1726,1727],{"class":454}," verify_jwt(auth.removeprefix(",[438,1729,1715],{"class":503},[438,1731,578],{"class":454},[438,1733,1734,1736,1738,1740,1742,1745,1747,1749,1752,1754,1757,1760,1763,1765],{"class":134,"line":624},[438,1735,627],{"class":450},[438,1737,1166],{"class":454},[438,1739,1674],{"class":503},[438,1741,1178],{"class":454},[438,1743,1744],{"class":503},"\"jwt\"",[438,1746,507],{"class":454},[438,1748,1684],{"class":503},[438,1750,1751],{"class":454},": claims[",[438,1753,712],{"class":503},[438,1755,1756],{"class":454},"], ",[438,1758,1759],{"class":503},"\"scopes\"",[438,1761,1762],{"class":454},": claims.get(",[438,1764,1759],{"class":503},[438,1766,1767],{"class":454},", [])}\n",[438,1769,1770,1773,1775,1777,1779,1782],{"class":134,"line":633},[438,1771,1772],{"class":450},"    raise",[438,1774,739],{"class":454},[438,1776,742],{"class":493},[438,1778,507],{"class":454},[438,1780,1781],{"class":503},"\"No credentials provided\"",[438,1783,513],{"class":454},[14,1785,1786,1787,1791,1792,1796],{},"That dependency migrates clients gradually instead of forcing a flag-day cutover, and it pairs cleanly with ",[18,1788,1790],{"href":1789},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fbest-practices-for-api-rate-limiting\u002F","rate limiting"," keyed off whichever principal the request carries. Return the principal from one place and your quota counters, your ",[18,1793,1795],{"href":1794},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Flogging-api-usage-events-to-postgres\u002F","usage event log"," and your billing rows all read from the same field.",[55,1798,64,1803,64,1806,64,1809,64,1812,64,1814,64,1819,64,1822,64,1826,64,1830,64,1834,64,1840,64,1844,64,1847,64,1850,64,1854,64,1858,64,1861,64,1864,64,1868,64,1874,64,1877,64,1880,64,1882,64,1885,64,1888,64,1890,64,1893,64,1895,64,1898,64,1900,64,1903,64,1905,64,1908,64,1911,64,1914,64,1917,64,1919,64,1923],{"viewBox":1799,"role":58,"ariaLabelledBy":1800,"xmlns":62,"style":63},"0 0 720 330",[1801,1802],"jwtkey-flow-t","jwtkey-flow-d",[66,1804,1805],{"id":1801},"Hybrid authentication dependency routing",[70,1807,1808],{"id":1802},"A request first checks for an API key header and, if present, hashes it and looks up the row to produce a key caller. Otherwise it checks for a bearer token and verifies the signature and expiry to produce a user caller. If neither header is present it returns a 401 with no credentials.",[74,1810],{"x":76,"y":76,"width":77,"height":1811,"fill":79},"330",[74,1813],{"x":319,"y":84,"width":136,"height":1494,"rx":93,"fill":94,"stroke":138,"style":320},[81,1815,1818],{"x":1816,"y":1817,"fill":85,"style":101},"95","68","Incoming request",[134,1820],{"x1":1267,"y1":1262,"x2":1821,"y2":1262,"stroke":106,"style":320},"196",[1823,1824],"polygon",{"points":1825,"fill":106},"196,57 204,62 196,67",[74,1827],{"x":1828,"y":84,"width":1829,"height":1494,"rx":93,"fill":79,"stroke":147,"style":96},"204","160",[81,1831,1833],{"x":1832,"y":1817,"fill":85,"style":101},"284","X-API-Key header?",[81,1835,1839],{"x":1836,"y":1837,"fill":106,"style":1838},"377","54","text-anchor:middle;font-size:11;font-family:var(--font-sans);","yes",[134,1841],{"x1":1842,"y1":1262,"x2":1843,"y2":1262,"stroke":106,"style":320},"364","390",[1823,1845],{"points":1846,"fill":106},"390,57 398,62 390,67",[74,1848],{"x":1849,"y":84,"width":1267,"height":1494,"rx":93,"fill":94,"stroke":147,"style":96},"398",[81,1851,1853],{"x":1852,"y":1817,"fill":85,"style":101},"483","Hash, then row lookup",[134,1855],{"x1":1856,"y1":1262,"x2":1857,"y2":1262,"stroke":106,"style":320},"568","594",[1823,1859],{"points":1860,"fill":106},"594,57 602,62 594,67",[74,1862],{"x":1863,"y":84,"width":1399,"height":1494,"rx":93,"fill":79,"stroke":147,"style":96},"602",[81,1865,1867],{"x":1866,"y":1817,"fill":85,"style":101},"652","key caller",[81,1869,1873],{"x":1870,"y":1871,"fill":106,"style":1872},"296","118","font-size:11;font-family:var(--font-sans);","no",[134,1875],{"x1":1832,"y1":100,"x2":1832,"y2":1876,"stroke":106,"style":320},"142",[1823,1878],{"points":1879,"fill":106},"279,142 284,150 289,142",[74,1881],{"x":1828,"y":136,"width":1829,"height":1494,"rx":93,"fill":79,"stroke":95,"style":96},[81,1883,1884],{"x":1832,"y":1414,"fill":85,"style":101},"Bearer token?",[81,1886,1839],{"x":1836,"y":1887,"fill":106,"style":1838},"164",[134,1889],{"x1":1842,"y1":364,"x2":1843,"y2":364,"stroke":106,"style":320},[1823,1891],{"points":1892,"fill":106},"390,167 398,172 390,177",[74,1894],{"x":1849,"y":136,"width":1267,"height":1494,"rx":93,"fill":94,"stroke":95,"style":96},[81,1896,1897],{"x":1852,"y":1414,"fill":85,"style":101},"Verify signature, exp",[134,1899],{"x1":1856,"y1":364,"x2":1857,"y2":364,"stroke":106,"style":320},[1823,1901],{"points":1902,"fill":106},"594,167 602,172 594,177",[74,1904],{"x":1863,"y":136,"width":1399,"height":1494,"rx":93,"fill":79,"stroke":95,"style":96},[81,1906,1907],{"x":1866,"y":1414,"fill":85,"style":101},"user caller",[81,1909,1873],{"x":1870,"y":1910,"fill":106,"style":1872},"228",[134,1912],{"x1":1832,"y1":155,"x2":1832,"y2":1913,"stroke":106,"style":320},"242",[1823,1915],{"points":1916,"fill":106},"279,242 284,250 289,242",[74,1918],{"x":1828,"y":1259,"width":1525,"height":1494,"rx":93,"fill":94,"stroke":165,"style":96},[81,1920,1922],{"x":1921,"y":1307,"fill":85,"style":101},"304","401 no credentials",[81,1924,1926],{"x":1497,"y":1925,"fill":106,"style":101},"318","One dependency, two credential types, one quota path.",[24,1928],{},[27,1930,1932],{"id":1931},"builder-verdict","Builder verdict",[14,1934,1935,1936,1939,1940,1944],{},"Ship both, but lead with API keys. Your first paying customers are almost always developers wiring your endpoint into their own backend, and a single long-lived, revocable, meterable key is exactly what they want — it maps one-to-one to a billing record, it survives their deploys, and it dies instantly when you delete the row. Store it as an HMAC fingerprint rather than a bcrypt hash so verification stays under a millisecond and your margin per request stays intact. Add JWTs the moment you build a user-facing dashboard or need per-request scopes, using a 15-minute TTL plus a small ",[35,1937,1938],{},"jti"," denylist to buy back the revocation you gave up. The hybrid dependency above is the production shape: keys on the machine routes, tokens on the human routes, one funnel into quota, ",[18,1941,1943],{"href":1942},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F","usage analytics"," and billing.",[24,1946],{},[27,1948,1950],{"id":1949},"faq","FAQ",[14,1952,1953,1956],{},[211,1954,1955],{},"Which choice actually costs less to run at a million requests a month?","\nJWTs, by roughly two orders of magnitude, but only against a badly built key path. HS256 verification burns about 20 seconds of CPU per million requests; an HMAC fingerprint plus a cached lookup costs about 8 minutes of CPU and a million Redis reads, which is still a few cents. bcrypt at cost 12 costs about 69 CPU-hours per million — real money and a wrecked p99. Fix the hashing, and the cost difference stops driving the decision.",[14,1958,1959,1962],{},[211,1960,1961],{},"Can I just use JWTs for everything and stay stateless?","\nYou can, and you will fight revocation forever. A developer whose credential leaks expects it dead immediately; with a long-lived token you cannot deliver that without a denylist, which reintroduces the exact state you were avoiding. Keys remain the better tool for revocable server-to-server access.",[14,1964,1965,1968],{},[211,1966,1967],{},"Are API keys less secure than JWTs?","\nNo — security depends on handling, not format. A raw, never-expiring key sitting in a log is dangerous; a peppered fingerprint with a rotation schedule is fine. A JWT with an unpinned algorithm is dangerous; a pinned, audience-checked, short-TTL token is fine. Both are safe when you hash storage, pin algorithms, set expiries and keep credentials out of URLs.",[14,1970,1971,1974,1975,108],{},[211,1972,1973],{},"How risky is migrating an existing key-only API to tokens?","\nLow, if you never cut over. Add the token path behind the same dependency, keep both live, watch your logs until key traffic on the dashboard routes hits zero, then retire that path. The one real risk is quota accounting drifting when the same customer arrives under two principal types — resolve both to a single account id before you count anything, and guard the free tier as described in ",[18,1976,1978],{"href":1977},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fpreventing-free-tier-abuse\u002F","preventing free-tier abuse",[14,1980,1981,1984],{},[211,1982,1983],{},"How often should I force customers to rotate credentials?","\nAnnually for keys, with a 30-day overlap window and email warnings at 30, 7 and 1 day, so no integration breaks unattended. Tokens rotate themselves every few minutes by design, so the only thing you schedule there is signing-key rotation — twice a year, with the previous key still accepted for one TTL.",[24,1986],{},[27,1988,1990],{"id":1989},"related","Related",[14,1992,1993],{},[211,1994,1995],{},"Same track:",[1997,1998,1999,2005,2011,2017],"ul",{},[2000,2001,2002,2004],"li",{},[18,2003,21],{"href":20}," — the full implementation of both paths, end to end.",[2000,2006,2007,2010],{},[18,2008,2009],{"href":1443},"Rotating API keys without downtime"," — the overlap window that makes long-lived keys safe.",[2000,2012,2013,2016],{},[18,2014,2015],{"href":1351},"Implementing the OAuth2 authorization code flow in FastAPI"," — where short-lived tokens come from once you add human logins.",[2000,2018,2019,2023],{},[18,2020,2022],{"href":2021},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fdebugging-401-unauthorized-api-errors\u002F","Debugging 401 Unauthorized API errors"," — what to check first when either path starts rejecting valid callers.",[14,2025,2026],{},[211,2027,2028],{},"Other tracks:",[1997,2030,2031],{},[2000,2032,2033,2036],{},[18,2034,2035],{"href":1314},"Calculating cost per API request"," — turn the verification latency numbers above into margin per call.",[2038,2039,2040],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":434,"searchDepth":447,"depth":447,"links":2042},[2043,2044,2045,2046,2047,2048,2049,2050,2051],{"id":29,"depth":447,"text":30},{"id":178,"depth":447,"text":179},{"id":416,"depth":447,"text":417},{"id":1320,"depth":447,"text":1321},{"id":1449,"depth":447,"text":1450},{"id":1546,"depth":447,"text":1547},{"id":1931,"depth":447,"text":1932},{"id":1949,"depth":447,"text":1950},{"id":1989,"depth":447,"text":1990},"Compare JWT and API keys for Python APIs — revocation, statelessness, expiry, rotation, verification cost — with runnable FastAPI code for each and a clear verdict.","md",{"pageTitle":2055,"type":2056,"datePublished":2057,"dateModified":2058},"JWT vs API Keys for Python APIs: Which to Use","article","2026-06-18","2026-07-23","\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis",{"title":5,"description":2052},"getting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis\u002Findex","vbVXFMAMDvPC7WCz_yrNQorS420LIDf_2kBDD2uk3OU",{"@context":2064,"@type":2065,"mainEntity":2066},"https:\u002F\u002Fschema.org","FAQPage",[2067,2072,2075,2078,2081],{"@type":2068,"name":1955,"acceptedAnswer":2069},"Question",{"@type":2070,"text":2071},"Answer","JWTs, by roughly two orders of magnitude, but only against a badly built key path. HS256 verification burns about 20 seconds of CPU per million requests; an HMAC fingerprint plus a cached lookup costs about 8 minutes of CPU and a million Redis reads, which is still a few cents. bcrypt at cost 12 costs about 69 CPU-hours per million — real money and a wrecked p99. Fix the hashing, and the cost difference stops driving the decision.",{"@type":2068,"name":1961,"acceptedAnswer":2073},{"@type":2070,"text":2074},"You can, and you will fight revocation forever. A developer whose credential leaks expects it dead immediately; with a long-lived token you cannot deliver that without a denylist, which reintroduces the exact state you were avoiding. Keys remain the better tool for revocable server-to-server access.",{"@type":2068,"name":1967,"acceptedAnswer":2076},{"@type":2070,"text":2077},"No — security depends on handling, not format. A raw, never-expiring key sitting in a log is dangerous; a peppered fingerprint with a rotation schedule is fine. A JWT with an unpinned algorithm is dangerous; a pinned, audience-checked, short-TTL token is fine. Both are safe when you hash storage, pin algorithms, set expiries and keep credentials out of URLs.",{"@type":2068,"name":1973,"acceptedAnswer":2079},{"@type":2070,"text":2080},"Low, if you never cut over. Add the token path behind the same dependency, keep both live, watch your logs until key traffic on the dashboard routes hits zero, then retire that path. The one real risk is quota accounting drifting when the same customer arrives under two principal types — resolve both to a single account id before you count anything, and guard the free tier as described in preventing free-tier abuse.",{"@type":2068,"name":1983,"acceptedAnswer":2082},{"@type":2070,"text":2083},"Annually for keys, with a 30-day overlap window and email warnings at 30, 7 and 1 day, so no integration breaks unattended. Tokens rotate themselves every few minutes by design, so the only thing you schedule there is signing-key rotation — twice a year, with the previous key still accepted for one TTL. ---",1784887028458]