[{"data":1,"prerenderedAt":5384},["ShallowReactive",2],{"page-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F":3,"faq-schema-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F":5363},{"id":4,"title":5,"body":6,"description":5352,"extension":5353,"meta":5354,"navigation":753,"path":5359,"seo":5360,"stem":5361,"__hash__":5362},"content\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Findex.md","Handling API Authentication in Python",{"type":7,"value":8,"toc":5337},"minimark",[9,13,23,31,180,183,188,191,241,258,404,419,437,439,443,446,454,594,602,604,608,619,622,702,709,1695,1698,1708,1840,1855,1857,1861,1864,2414,2425,2428,2510,2524,2526,2530,2554,3097,3108,3189,3281,3284,3611,3613,3617,3625,3628,4058,4071,4167,4170,4172,4176,4183,4522,4530,4538,4540,4544,4681,4688,4690,4694,4788,4790,4794,4797,4898,4911,4953,4956,5180,5201,5203,5207,5210,5217,5230,5232,5236,5242,5253,5259,5269,5283,5285,5289,5294,5314,5319,5333],[10,11,5],"h1",{"id":12},"handling-api-authentication-in-python",[14,15,16,17,22],"p",{},"Authentication is the gate between your API and revenue. Get it wrong and you leak customer data, hand out free quota, or lock out paying users with confusing 401s. This guide walks through the four jobs every commercial Python API has to do: authenticate the requests your customers send you, issue and verify your own tokens, keep the verification itself cheap enough that it does not eat your margin, and safely supply credentials to the third-party APIs you consume. Part of the ",[18,19,21],"a",{"href":20},"\u002Fgetting-started-with-python-apis-for-builders\u002F","Getting Started with Python APIs for Builders"," guide.",[14,24,25,26,30],{},"We use FastAPI throughout because its dependency-injection model turns auth into a single reusable function you attach per route, and because the same dependency gives you the principal object your billing and quota code needs. If you are still deciding which credential format to issue, read ",[18,27,29],{"href":28},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fjwt-vs-api-keys-for-python-apis\u002F","JWT vs API keys for Python APIs"," first — the short version is API keys for server-to-server and metered billing, JWTs for user sessions and scopes. This page implements both, plus the operational parts nobody writes down: the lookup index that stops a full table scan on every request, the cache that keeps bcrypt off the hot path, and the failure modes that generate support tickets.",[32,33,41,42,41,46,41,50,41,69,41,76,41,83,41,92,41,99,41,104,41,111,41,114,41,119,41,124,41,130,41,134,41,138,41,143,41,147,41,150,41,154,41,158,41,163,41,167,41,172,41,177],"svg",{"viewBox":34,"role":35,"ariaLabelledBy":36,"xmlns":39,"style":40},"0 0 720 300","img",[37,38],"apiauth-flow-t","apiauth-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[43,44,45],"title",{"id":37},"Authentication decision and verification flow",[47,48,49],"desc",{"id":38},"An incoming request is classified as an API key or a JWT bearer token, verified against the indexed key store or by signature, checked for scopes, then routed to the handler or rejected with 401 or 403.",[51,52,53,54,41],"defs",{},"\n    ",[55,56,63,64,53],"marker",{"id":57,"viewBox":58,"refX":59,"refY":60,"markerWidth":61,"markerHeight":61,"orient":62},"apiauth-flow-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[65,66],"path",{"d":67,"fill":68},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[70,71],"rect",{"x":72,"y":72,"width":73,"height":74,"fill":75},"0","720","300","var(--c-surface)",[77,78,82],"text",{"x":79,"y":80,"fill":68,"style":81},"20","30","text-anchor:start;font-size:13;font-family:var(--font-sans);","Two credential paths, one handler",[70,84],{"x":79,"y":85,"width":86,"height":87,"rx":88,"fill":89,"stroke":90,"style":91},"120","150","56","10","var(--c-surface-alt)","var(--c-blue)","stroke-width:2;",[77,93,98],{"x":94,"y":95,"fill":96,"style":97},"95","146","var(--c-text)","text-anchor:middle;font-size:14;font-family:var(--font-sans);","Request",[77,100,103],{"x":94,"y":101,"fill":68,"style":102},"164","text-anchor:middle;font-size:11;font-family:var(--font-sans);","header only",[105,106],"line",{"x1":107,"y1":108,"x2":109,"y2":108,"stroke":68,"style":110},"170","148","225","stroke-width:2;marker-end:url(#apiauth-flow-arrow);",[70,112],{"x":109,"y":85,"width":86,"height":87,"rx":88,"fill":89,"stroke":113,"style":91},"var(--c-teal)",[77,115,118],{"x":74,"y":116,"fill":96,"style":117},"144","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Classify",[77,120,123],{"x":74,"y":121,"fill":68,"style":122},"162","text-anchor:middle;font-size:12;font-family:var(--font-sans);","key or JWT?",[105,125],{"x1":126,"y1":127,"x2":128,"y2":129,"stroke":68,"style":110},"375","135","430","72",[105,131],{"x1":126,"y1":132,"x2":128,"y2":133,"stroke":68,"style":110},"160","224",[70,135],{"x":128,"y":136,"width":86,"height":87,"rx":88,"fill":89,"stroke":137,"style":91},"44","var(--c-yellow)",[77,139,142],{"x":140,"y":141,"fill":96,"style":117},"505","68","Indexed lookup",[77,144,146],{"x":140,"y":145,"fill":68,"style":122},"86","one bcrypt check",[70,148],{"x":128,"y":149,"width":86,"height":87,"rx":88,"fill":89,"stroke":137,"style":91},"198",[77,151,153],{"x":140,"y":152,"fill":96,"style":117},"222","Verify signature",[77,155,157],{"x":140,"y":156,"fill":68,"style":122},"240","exp + scopes",[105,159],{"x1":160,"y1":129,"x2":161,"y2":162,"stroke":68,"style":110},"580","628","134",[105,164],{"x1":160,"y1":165,"x2":161,"y2":166,"stroke":68,"style":110},"226","163",[70,168],{"x":169,"y":85,"width":170,"height":87,"rx":88,"fill":89,"stroke":171,"style":91},"630","80","var(--c-coral)",[77,173,176],{"x":174,"y":175,"fill":96,"style":117},"670","145","Handler",[77,178,179],{"x":174,"y":166,"fill":68,"style":102},"or 401\u002F403",[181,182],"hr",{},[184,185,187],"h2",{"id":186},"prerequisites","Prerequisites",[14,189,190],{},"You need Python 3.11+ and a FastAPI project. Install the auth dependencies:",[192,193,198],"pre",{"className":194,"code":195,"language":196,"meta":197,"style":197},"language-bash shiki shiki-themes github-light github-dark","pip install \"fastapi>=0.110\" \"uvicorn[standard]\" \"pyjwt>=2.8\" \"passlib[bcrypt]>=1.7\" \\\n    \"redis>=5.0\" \"httpx>=0.27\" python-multipart\n","bash","",[199,200,201,229],"code",{"__ignoreMap":197},[202,203,205,209,213,216,219,222,225],"span",{"class":105,"line":204},1,[202,206,208],{"class":207},"sScJk","pip",[202,210,212],{"class":211},"sZZnC"," install",[202,214,215],{"class":211}," \"fastapi>=0.110\"",[202,217,218],{"class":211}," \"uvicorn[standard]\"",[202,220,221],{"class":211}," \"pyjwt>=2.8\"",[202,223,224],{"class":211}," \"passlib[bcrypt]>=1.7\"",[202,226,228],{"class":227},"sj4cs"," \\\n",[202,230,232,235,238],{"class":105,"line":231},2,[202,233,234],{"class":211},"    \"redis>=5.0\"",[202,236,237],{"class":211}," \"httpx>=0.27\"",[202,239,240],{"class":211}," python-multipart\n",[14,242,243,246,247,253,254,257],{},[199,244,245],{},"python-multipart"," is required for the OAuth2 password form. We use ",[18,248,252],{"href":249,"rel":250},"https:\u002F\u002Fpyjwt.readthedocs.io\u002F",[251],"nofollow","PyJWT"," for tokens; ",[199,255,256],{},"python-jose"," is a drop-in alternative if you need JWE encryption, but PyJWT has a smaller dependency tree and a faster release cadence on security fixes, so start there. Set these environment variables before running — never hardcode them:",[192,259,261],{"className":194,"code":260,"language":196,"meta":197,"style":197},"export JWT_SECRET=\"$(openssl rand -hex 32)\"     # 256-bit signing key\nexport API_KEY_PEPPER=\"$(openssl rand -hex 16)\"  # server-side pepper for key hashing\nexport JWT_ALGORITHM=\"HS256\"\nexport JWT_TTL_SECONDS=\"900\"                     # 15-minute access tokens\nexport JWT_ISSUER=\"https:\u002F\u002Fapi.example.com\"\nexport JWT_AUDIENCE=\"api\"\nexport API_KEY_PREFIX=\"pk_live\"\nexport AUTH_CACHE_TTL_SECONDS=\"300\"\n",[199,262,263,298,323,336,352,365,378,391],{"__ignoreMap":197},[202,264,265,269,273,276,279,282,285,288,291,294],{"class":105,"line":204},[202,266,268],{"class":267},"szBVR","export",[202,270,272],{"class":271},"sVt8B"," JWT_SECRET",[202,274,275],{"class":267},"=",[202,277,278],{"class":211},"\"$(",[202,280,281],{"class":207},"openssl",[202,283,284],{"class":211}," rand ",[202,286,287],{"class":227},"-hex",[202,289,290],{"class":227}," 32",[202,292,293],{"class":211},")\"",[202,295,297],{"class":296},"sJ8bj","     # 256-bit signing key\n",[202,299,300,302,305,307,309,311,313,315,318,320],{"class":105,"line":231},[202,301,268],{"class":267},[202,303,304],{"class":271}," API_KEY_PEPPER",[202,306,275],{"class":267},[202,308,278],{"class":211},[202,310,281],{"class":207},[202,312,284],{"class":211},[202,314,287],{"class":227},[202,316,317],{"class":227}," 16",[202,319,293],{"class":211},[202,321,322],{"class":296},"  # server-side pepper for key hashing\n",[202,324,326,328,331,333],{"class":105,"line":325},3,[202,327,268],{"class":267},[202,329,330],{"class":271}," JWT_ALGORITHM",[202,332,275],{"class":267},[202,334,335],{"class":211},"\"HS256\"\n",[202,337,339,341,344,346,349],{"class":105,"line":338},4,[202,340,268],{"class":267},[202,342,343],{"class":271}," JWT_TTL_SECONDS",[202,345,275],{"class":267},[202,347,348],{"class":211},"\"900\"",[202,350,351],{"class":296},"                     # 15-minute access tokens\n",[202,353,355,357,360,362],{"class":105,"line":354},5,[202,356,268],{"class":267},[202,358,359],{"class":271}," JWT_ISSUER",[202,361,275],{"class":267},[202,363,364],{"class":211},"\"https:\u002F\u002Fapi.example.com\"\n",[202,366,368,370,373,375],{"class":105,"line":367},6,[202,369,268],{"class":267},[202,371,372],{"class":271}," JWT_AUDIENCE",[202,374,275],{"class":267},[202,376,377],{"class":211},"\"api\"\n",[202,379,381,383,386,388],{"class":105,"line":380},7,[202,382,268],{"class":267},[202,384,385],{"class":271}," API_KEY_PREFIX",[202,387,275],{"class":267},[202,389,390],{"class":211},"\"pk_live\"\n",[202,392,394,396,399,401],{"class":105,"line":393},8,[202,395,268],{"class":267},[202,397,398],{"class":271}," AUTH_CACHE_TTL_SECONDS",[202,400,275],{"class":267},[202,402,403],{"class":211},"\"300\"\n",[14,405,406,407,410,411,414,415,418],{},"Two of those deserve a word now. ",[199,408,409],{},"API_KEY_PEPPER"," is a secret that lives only in your application environment, never in the database — it means a stolen database dump alone cannot be brute-forced offline. ",[199,412,413],{},"JWT_ISSUER"," and ",[199,416,417],{},"JWT_AUDIENCE"," look like ceremony on a single-service API, but the moment you add a second service, or a staging environment that shares a secret manager, they are what stops a token minted for one audience being replayed against another.",[14,420,421,422,426,427,431,432,436],{},"This builds on the project layout in ",[18,423,425],{"href":424},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","Setting Up FastAPI",", and it assumes your app already talks to a database — the async patterns in ",[18,428,430],{"href":429},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async database access with SQLAlchemy"," apply directly to the key table below. Auth pairs naturally with throttling, so plan to add ",[18,433,435],{"href":434},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fbest-practices-for-api-rate-limiting\u002F","API rate limiting"," to the same dependency chain: the principal you resolve here is exactly the identity your limiter should key on.",[181,438],{},[184,440,442],{"id":441},"choose-the-credential-model-before-you-write-code","Choose the credential model before you write code",[14,444,445],{},"Most auth rewrites happen because someone picked a credential type for the wrong reason. Decide on three axes only: how fast you need revocation to take effect, how much server state you are willing to carry per request, and who the caller actually is. A backend cron job hitting your API from a customer's server is a completely different customer from a browser session in your dashboard, and they should not share a credential type.",[14,447,448,449,453],{},"Issue API keys when the caller is a machine you bill by usage. Keys are long-lived, easy to paste into a config file or a CI secret, trivially attributable to one account, and revocable in one DELETE. Issue JWTs when the caller is a human session that needs fine-grained permissions and you want zero database round-trips per request. Use the OAuth2 authorization code flow only when you are acting on behalf of a customer's account on someone else's platform — that is a client-side job, covered end to end in ",[18,450,452],{"href":451},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Fimplementing-oauth2-authorization-code-flow-in-fastapi\u002F","implementing the OAuth2 authorization code flow in FastAPI",".",[32,455,41,460,41,463,41,466,41,469,41,473,41,479,41,483,41,486,41,489,41,493,41,496,41,499,41,503,41,507,41,511,41,515,41,521,41,525,41,528,41,532,41,535,41,538,41,541,41,544,41,547,41,551,41,554,41,558,41,561,41,564,41,567,41,570,41,574,41,578,41,582,41,585,41,588,41,591],{"viewBox":456,"role":35,"ariaLabelledBy":457,"xmlns":39,"style":40},"0 0 720 280",[458,459],"apiauth-matrix-t","apiauth-matrix-d",[43,461,462],{"id":458},"Credential model comparison matrix",[47,464,465],{"id":459},"A three-row matrix comparing hashed API keys, JWT access tokens and OAuth2 grants across revocation speed, server state required, and the caller each one fits best.",[70,467],{"x":72,"y":72,"width":73,"height":468,"fill":75},"280",[70,470],{"x":79,"y":80,"width":471,"height":472,"fill":89},"680","40",[70,474],{"x":79,"y":80,"width":471,"height":475,"fill":476,"stroke":477,"style":478},"235","none","var(--c-border)","stroke-width:1.5;",[105,480],{"x1":481,"y1":80,"x2":481,"y2":482,"stroke":477,"style":478},"175","265",[105,484],{"x1":485,"y1":80,"x2":485,"y2":482,"stroke":477,"style":478},"350",[105,487],{"x1":488,"y1":80,"x2":488,"y2":482,"stroke":477,"style":478},"525",[105,490],{"x1":79,"y1":491,"x2":492,"y2":491,"stroke":477,"style":478},"70","700",[105,494],{"x1":79,"y1":127,"x2":492,"y2":127,"stroke":477,"style":495},"stroke-width:1;",[105,497],{"x1":79,"y1":498,"x2":492,"y2":498,"stroke":477,"style":495},"200",[77,500,502],{"x":501,"y":87,"fill":96,"style":117},"97","Credential",[77,504,506],{"x":505,"y":87,"fill":96,"style":117},"262","Revocation",[77,508,510],{"x":509,"y":87,"fill":96,"style":117},"437","Server state",[77,512,514],{"x":513,"y":87,"fill":96,"style":117},"612","Best fit",[70,516],{"x":517,"y":518,"width":519,"height":520,"fill":90},"24","90","4","26",[77,522,524],{"x":501,"y":523,"fill":96,"style":122},"108","API key (hashed)",[77,526,527],{"x":505,"y":501,"fill":96,"style":122},"Instant",[77,529,531],{"x":505,"y":530,"fill":68,"style":122},"117","delete the row",[77,533,534],{"x":509,"y":501,"fill":96,"style":122},"One indexed",[77,536,537],{"x":509,"y":530,"fill":68,"style":122},"key lookup",[77,539,540],{"x":513,"y":501,"fill":96,"style":122},"Server-to-server,",[77,542,543],{"x":513,"y":530,"fill":68,"style":122},"metered billing",[70,545],{"x":517,"y":546,"width":519,"height":520,"fill":113},"155",[77,548,550],{"x":501,"y":549,"fill":96,"style":122},"173","JWT access token",[77,552,553],{"x":505,"y":121,"fill":96,"style":122},"Only at expiry",[77,555,557],{"x":505,"y":556,"fill":68,"style":122},"182","or via a denylist",[77,559,560],{"x":509,"y":549,"fill":96,"style":122},"None",[77,562,563],{"x":513,"y":121,"fill":96,"style":122},"User sessions,",[77,565,566],{"x":513,"y":556,"fill":68,"style":122},"scoped dashboards",[70,568],{"x":517,"y":569,"width":519,"height":520,"fill":171},"220",[77,571,573],{"x":501,"y":572,"fill":96,"style":122},"238","OAuth2 grant",[77,575,577],{"x":505,"y":576,"fill":96,"style":122},"227","Customer or",[77,579,581],{"x":505,"y":580,"fill":68,"style":122},"247","provider revokes",[77,583,584],{"x":509,"y":576,"fill":96,"style":122},"Encrypted",[77,586,587],{"x":509,"y":580,"fill":68,"style":122},"token store",[77,589,590],{"x":513,"y":576,"fill":96,"style":122},"Acting on a",[77,592,593],{"x":513,"y":580,"fill":68,"style":122},"customer account",[14,595,596,597,601],{},"The commercial reading of that matrix matters more than the technical one. Keys map cleanly to invoices because one key equals one billable identity, which is why every usage-metering design in ",[18,598,600],{"href":599},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F","tracking API usage and analytics"," starts from a key id. JWTs cost you nothing per request but cost you a revocation story, and \"we cannot cut off that leaked token for another fifteen minutes\" is a sentence you will one day say to a customer. Pick your defaults with that conversation in mind.",[181,603],{},[184,605,607],{"id":606},"step-1-hashed-api-keys-behind-a-prefixed-lookup-id","Step 1: Hashed API keys behind a prefixed lookup id",[14,609,610,611,615,616,618],{},"Customers integrating server-to-server want a long-lived secret they paste into a config file. The cardinal rule: ",[612,613,614],"strong",{},"store only a hash of the key, never the key itself."," If your database leaks, raw keys are live credentials; hashes are not. We hash with bcrypt plus a pepper from ",[199,617,409],{},", and we generate the secret from a cryptographically secure source.",[14,620,621],{},"The part most first implementations get wrong is the lookup. Hashes are not searchable, so a naive implementation loops over every stored hash and runs bcrypt against each one — a full table scan multiplied by a deliberately slow hash function. At a hundred customers that is a two-second request. Fix it structurally: give every key a non-secret, indexed id and embed it in the key string itself, so verification touches exactly one row and runs bcrypt exactly once.",[32,623,41,628,41,631,41,634,41,637,41,642,41,647,41,652,41,655,41,658,41,661,41,665,41,669,41,671,41,673,41,677,41,681,41,684,41,687,41,690,41,693,41,698],{"viewBox":624,"role":35,"ariaLabelledBy":625,"xmlns":39,"style":40},"0 0 720 250",[626,627],"apiauth-key-t","apiauth-key-d",[43,629,630],{"id":626},"Anatomy of an issued API key",[47,632,633],{"id":627},"An API key split into three labelled segments: a public environment prefix, an indexed key id used for the database lookup, and a 256-bit random secret that is bcrypt-hashed before storage.",[70,635],{"x":72,"y":72,"width":73,"height":636,"fill":75},"250",[77,638,641],{"x":639,"y":640,"fill":68,"style":81},"60","38","Anatomy of a customer key",[70,643],{"x":639,"y":639,"width":644,"height":645,"rx":646,"fill":89,"stroke":90,"style":91},"130","54","6",[77,648,651],{"x":649,"y":650,"fill":96,"style":97},"125","93","pk_live",[70,653],{"x":654,"y":639,"width":86,"height":645,"rx":646,"fill":89,"stroke":113,"style":91},"190",[77,656,657],{"x":482,"y":650,"fill":96,"style":97},"3f9c1a7b0d42",[70,659],{"x":660,"y":639,"width":74,"height":645,"rx":646,"fill":89,"stroke":171,"style":91},"340",[77,662,664],{"x":663,"y":650,"fill":96,"style":97},"490","Vn8s-KdQ7pR3xJ2f...9pQ",[105,666],{"x1":649,"y1":667,"x2":649,"y2":668,"stroke":477,"style":478},"114","138",[105,670],{"x1":482,"y1":667,"x2":482,"y2":668,"stroke":477,"style":478},[105,672],{"x1":663,"y1":667,"x2":663,"y2":668,"stroke":477,"style":478},[77,674,676],{"x":649,"y":675,"fill":96,"style":122},"154","Public prefix",[77,678,680],{"x":649,"y":679,"fill":68,"style":122},"172","leak scanners match it",[77,682,683],{"x":482,"y":675,"fill":96,"style":122},"Indexed key id",[77,685,686],{"x":482,"y":679,"fill":68,"style":122},"one row, no scan",[77,688,689],{"x":663,"y":675,"fill":96,"style":122},"256-bit secret",[77,691,692],{"x":663,"y":679,"fill":68,"style":122},"bcrypt-hashed at rest",[70,694],{"x":85,"y":149,"width":695,"height":696,"rx":697,"fill":89,"stroke":477,"style":478},"480","34","8",[77,699,701],{"x":700,"y":569,"fill":96,"style":122},"360","The database stores the hash only. The raw key is shown once.",[14,703,704,705,708],{},"The prefix is not decoration either. A recognisable, product-specific prefix like ",[199,706,707],{},"pk_live_"," lets GitHub's secret scanning and every commercial leak-detection service spot your credentials in a public repository and tell you about it. Ship a prefix from day one; retrofitting one across a live customer base is a migration you do not want.",[192,710,714],{"className":711,"code":712,"language":713,"meta":197,"style":197},"language-python shiki shiki-themes github-light github-dark","# apikeys.py\nimport os\nimport secrets\nfrom dataclasses import dataclass\n\nfrom fastapi import Depends, HTTPException, status\nfrom fastapi.security import APIKeyHeader\nfrom passlib.hash import bcrypt\n\nAPI_KEY_PEPPER = os.getenv(\"API_KEY_PEPPER\", \"\")\nAPI_KEY_PREFIX = os.getenv(\"API_KEY_PREFIX\", \"pk_test\")\nBCRYPT_ROUNDS = int(os.getenv(\"BCRYPT_ROUNDS\", \"12\"))\n\napi_key_header = APIKeyHeader(name=\"X-API-Key\", auto_error=False)\n\n# A hash of a throwaway secret, used to burn equal time on unknown key ids.\n_DUMMY_HASH = bcrypt.using(rounds=BCRYPT_ROUNDS).hash(\"decoy\" + API_KEY_PEPPER)\n\n\n@dataclass(frozen=True)\nclass Principal:\n    key_id: str\n    account_id: str\n    plan: str\n\n\n# In production this is one indexed table: primary key = key_id.\nKEY_ROWS: dict[str, dict] = {}\n\n\ndef issue_api_key(account_id: str, plan: str = \"free\") -> str:\n    \"\"\"Return the raw key ONCE; persist only its hash.\"\"\"\n    key_id = secrets.token_hex(6)\n    secret = secrets.token_urlsafe(32)\n    KEY_ROWS[key_id] = {\n        \"hash\": bcrypt.using(rounds=BCRYPT_ROUNDS).hash(secret + API_KEY_PEPPER),\n        \"account_id\": account_id,\n        \"plan\": plan,\n        \"revoked\": False,\n    }\n    return f\"{API_KEY_PREFIX}_{key_id}_{secret}\"\n\n\ndef parse_api_key(raw: str) -> tuple[str, str] | None:\n    body = raw.removeprefix(f\"{API_KEY_PREFIX}_\")\n    if body == raw:\n        return None\n    key_id, _, secret = body.partition(\"_\")\n    return (key_id, secret) if key_id and secret else None\n\n\ndef verify_api_key(raw: str) -> Principal:\n    parsed = parse_api_key(raw)\n    if parsed is None:\n        bcrypt.verify(\"decoy\" + API_KEY_PEPPER, _DUMMY_HASH)\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Malformed API key\")\n    key_id, secret = parsed\n    row = KEY_ROWS.get(key_id)\n    if row is None or row[\"revoked\"]:\n        bcrypt.verify(\"decoy\" + API_KEY_PEPPER, _DUMMY_HASH)\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid API key\")\n    if not bcrypt.verify(secret + API_KEY_PEPPER, row[\"hash\"]):\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid API key\")\n    return Principal(key_id=key_id, account_id=row[\"account_id\"], plan=row[\"plan\"])\n\n\nasync def require_api_key(raw: str | None = Depends(api_key_header)) -> Principal:\n    if not raw:\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Missing X-API-Key header\")\n    return verify_api_key(raw)\n","python",[199,715,716,721,729,736,749,755,767,779,791,796,819,839,864,869,901,906,912,943,948,953,972,984,993,1001,1009,1014,1019,1025,1050,1055,1060,1091,1097,1112,1128,1142,1168,1177,1186,1200,1206,1245,1250,1255,1287,1310,1325,1334,1350,1375,1380,1385,1400,1411,1426,1444,1463,1474,1488,1512,1529,1545,1569,1584,1626,1631,1636,1662,1671,1687],{"__ignoreMap":197},[202,717,718],{"class":105,"line":204},[202,719,720],{"class":296},"# apikeys.py\n",[202,722,723,726],{"class":105,"line":231},[202,724,725],{"class":267},"import",[202,727,728],{"class":271}," os\n",[202,730,731,733],{"class":105,"line":325},[202,732,725],{"class":267},[202,734,735],{"class":271}," secrets\n",[202,737,738,741,744,746],{"class":105,"line":338},[202,739,740],{"class":267},"from",[202,742,743],{"class":271}," dataclasses ",[202,745,725],{"class":267},[202,747,748],{"class":271}," dataclass\n",[202,750,751],{"class":105,"line":354},[202,752,754],{"emptyLinePlaceholder":753},true,"\n",[202,756,757,759,762,764],{"class":105,"line":367},[202,758,740],{"class":267},[202,760,761],{"class":271}," fastapi ",[202,763,725],{"class":267},[202,765,766],{"class":271}," Depends, HTTPException, status\n",[202,768,769,771,774,776],{"class":105,"line":380},[202,770,740],{"class":267},[202,772,773],{"class":271}," fastapi.security ",[202,775,725],{"class":267},[202,777,778],{"class":271}," APIKeyHeader\n",[202,780,781,783,786,788],{"class":105,"line":393},[202,782,740],{"class":267},[202,784,785],{"class":271}," passlib.hash ",[202,787,725],{"class":267},[202,789,790],{"class":271}," bcrypt\n",[202,792,794],{"class":105,"line":793},9,[202,795,754],{"emptyLinePlaceholder":753},[202,797,799,801,804,807,810,813,816],{"class":105,"line":798},10,[202,800,409],{"class":227},[202,802,803],{"class":267}," =",[202,805,806],{"class":271}," os.getenv(",[202,808,809],{"class":211},"\"API_KEY_PEPPER\"",[202,811,812],{"class":271},", ",[202,814,815],{"class":211},"\"\"",[202,817,818],{"class":271},")\n",[202,820,822,825,827,829,832,834,837],{"class":105,"line":821},11,[202,823,824],{"class":227},"API_KEY_PREFIX",[202,826,803],{"class":267},[202,828,806],{"class":271},[202,830,831],{"class":211},"\"API_KEY_PREFIX\"",[202,833,812],{"class":271},[202,835,836],{"class":211},"\"pk_test\"",[202,838,818],{"class":271},[202,840,842,845,847,850,853,856,858,861],{"class":105,"line":841},12,[202,843,844],{"class":227},"BCRYPT_ROUNDS",[202,846,803],{"class":267},[202,848,849],{"class":227}," int",[202,851,852],{"class":271},"(os.getenv(",[202,854,855],{"class":211},"\"BCRYPT_ROUNDS\"",[202,857,812],{"class":271},[202,859,860],{"class":211},"\"12\"",[202,862,863],{"class":271},"))\n",[202,865,867],{"class":105,"line":866},13,[202,868,754],{"emptyLinePlaceholder":753},[202,870,872,875,877,880,884,886,889,891,894,896,899],{"class":105,"line":871},14,[202,873,874],{"class":271},"api_key_header ",[202,876,275],{"class":267},[202,878,879],{"class":271}," APIKeyHeader(",[202,881,883],{"class":882},"s4XuR","name",[202,885,275],{"class":267},[202,887,888],{"class":211},"\"X-API-Key\"",[202,890,812],{"class":271},[202,892,893],{"class":882},"auto_error",[202,895,275],{"class":267},[202,897,898],{"class":227},"False",[202,900,818],{"class":271},[202,902,904],{"class":105,"line":903},15,[202,905,754],{"emptyLinePlaceholder":753},[202,907,909],{"class":105,"line":908},16,[202,910,911],{"class":296},"# A hash of a throwaway secret, used to burn equal time on unknown key ids.\n",[202,913,915,918,920,923,926,928,930,933,936,939,941],{"class":105,"line":914},17,[202,916,917],{"class":227},"_DUMMY_HASH",[202,919,803],{"class":267},[202,921,922],{"class":271}," bcrypt.using(",[202,924,925],{"class":882},"rounds",[202,927,275],{"class":267},[202,929,844],{"class":227},[202,931,932],{"class":271},").hash(",[202,934,935],{"class":211},"\"decoy\"",[202,937,938],{"class":267}," +",[202,940,304],{"class":227},[202,942,818],{"class":271},[202,944,946],{"class":105,"line":945},18,[202,947,754],{"emptyLinePlaceholder":753},[202,949,951],{"class":105,"line":950},19,[202,952,754],{"emptyLinePlaceholder":753},[202,954,956,959,962,965,967,970],{"class":105,"line":955},20,[202,957,958],{"class":207},"@dataclass",[202,960,961],{"class":271},"(",[202,963,964],{"class":882},"frozen",[202,966,275],{"class":267},[202,968,969],{"class":227},"True",[202,971,818],{"class":271},[202,973,975,978,981],{"class":105,"line":974},21,[202,976,977],{"class":267},"class",[202,979,980],{"class":207}," Principal",[202,982,983],{"class":271},":\n",[202,985,987,990],{"class":105,"line":986},22,[202,988,989],{"class":271},"    key_id: ",[202,991,992],{"class":227},"str\n",[202,994,996,999],{"class":105,"line":995},23,[202,997,998],{"class":271},"    account_id: ",[202,1000,992],{"class":227},[202,1002,1004,1007],{"class":105,"line":1003},24,[202,1005,1006],{"class":271},"    plan: ",[202,1008,992],{"class":227},[202,1010,1012],{"class":105,"line":1011},25,[202,1013,754],{"emptyLinePlaceholder":753},[202,1015,1017],{"class":105,"line":1016},26,[202,1018,754],{"emptyLinePlaceholder":753},[202,1020,1022],{"class":105,"line":1021},27,[202,1023,1024],{"class":296},"# In production this is one indexed table: primary key = key_id.\n",[202,1026,1028,1031,1034,1037,1039,1042,1045,1047],{"class":105,"line":1027},28,[202,1029,1030],{"class":227},"KEY_ROWS",[202,1032,1033],{"class":271},": dict[",[202,1035,1036],{"class":227},"str",[202,1038,812],{"class":271},[202,1040,1041],{"class":227},"dict",[202,1043,1044],{"class":271},"] ",[202,1046,275],{"class":267},[202,1048,1049],{"class":271}," {}\n",[202,1051,1053],{"class":105,"line":1052},29,[202,1054,754],{"emptyLinePlaceholder":753},[202,1056,1058],{"class":105,"line":1057},30,[202,1059,754],{"emptyLinePlaceholder":753},[202,1061,1063,1066,1069,1072,1074,1077,1079,1081,1084,1087,1089],{"class":105,"line":1062},31,[202,1064,1065],{"class":267},"def",[202,1067,1068],{"class":207}," issue_api_key",[202,1070,1071],{"class":271},"(account_id: ",[202,1073,1036],{"class":227},[202,1075,1076],{"class":271},", plan: ",[202,1078,1036],{"class":227},[202,1080,803],{"class":267},[202,1082,1083],{"class":211}," \"free\"",[202,1085,1086],{"class":271},") -> ",[202,1088,1036],{"class":227},[202,1090,983],{"class":271},[202,1092,1094],{"class":105,"line":1093},32,[202,1095,1096],{"class":211},"    \"\"\"Return the raw key ONCE; persist only its hash.\"\"\"\n",[202,1098,1100,1103,1105,1108,1110],{"class":105,"line":1099},33,[202,1101,1102],{"class":271},"    key_id ",[202,1104,275],{"class":267},[202,1106,1107],{"class":271}," secrets.token_hex(",[202,1109,646],{"class":227},[202,1111,818],{"class":271},[202,1113,1115,1118,1120,1123,1126],{"class":105,"line":1114},34,[202,1116,1117],{"class":271},"    secret ",[202,1119,275],{"class":267},[202,1121,1122],{"class":271}," secrets.token_urlsafe(",[202,1124,1125],{"class":227},"32",[202,1127,818],{"class":271},[202,1129,1131,1134,1137,1139],{"class":105,"line":1130},35,[202,1132,1133],{"class":227},"    KEY_ROWS",[202,1135,1136],{"class":271},"[key_id] ",[202,1138,275],{"class":267},[202,1140,1141],{"class":271}," {\n",[202,1143,1145,1148,1151,1153,1155,1157,1160,1163,1165],{"class":105,"line":1144},36,[202,1146,1147],{"class":211},"        \"hash\"",[202,1149,1150],{"class":271},": bcrypt.using(",[202,1152,925],{"class":882},[202,1154,275],{"class":267},[202,1156,844],{"class":227},[202,1158,1159],{"class":271},").hash(secret ",[202,1161,1162],{"class":267},"+",[202,1164,304],{"class":227},[202,1166,1167],{"class":271},"),\n",[202,1169,1171,1174],{"class":105,"line":1170},37,[202,1172,1173],{"class":211},"        \"account_id\"",[202,1175,1176],{"class":271},": account_id,\n",[202,1178,1180,1183],{"class":105,"line":1179},38,[202,1181,1182],{"class":211},"        \"plan\"",[202,1184,1185],{"class":271},": plan,\n",[202,1187,1189,1192,1195,1197],{"class":105,"line":1188},39,[202,1190,1191],{"class":211},"        \"revoked\"",[202,1193,1194],{"class":271},": ",[202,1196,898],{"class":227},[202,1198,1199],{"class":271},",\n",[202,1201,1203],{"class":105,"line":1202},40,[202,1204,1205],{"class":271},"    }\n",[202,1207,1209,1212,1215,1218,1221,1224,1227,1230,1233,1235,1237,1240,1242],{"class":105,"line":1208},41,[202,1210,1211],{"class":267},"    return",[202,1213,1214],{"class":267}," f",[202,1216,1217],{"class":211},"\"",[202,1219,1220],{"class":227},"{API_KEY_PREFIX}",[202,1222,1223],{"class":211},"_",[202,1225,1226],{"class":227},"{",[202,1228,1229],{"class":271},"key_id",[202,1231,1232],{"class":227},"}",[202,1234,1223],{"class":211},[202,1236,1226],{"class":227},[202,1238,1239],{"class":271},"secret",[202,1241,1232],{"class":227},[202,1243,1244],{"class":211},"\"\n",[202,1246,1248],{"class":105,"line":1247},42,[202,1249,754],{"emptyLinePlaceholder":753},[202,1251,1253],{"class":105,"line":1252},43,[202,1254,754],{"emptyLinePlaceholder":753},[202,1256,1258,1260,1263,1266,1268,1271,1273,1275,1277,1279,1282,1285],{"class":105,"line":1257},44,[202,1259,1065],{"class":267},[202,1261,1262],{"class":207}," parse_api_key",[202,1264,1265],{"class":271},"(raw: ",[202,1267,1036],{"class":227},[202,1269,1270],{"class":271},") -> tuple[",[202,1272,1036],{"class":227},[202,1274,812],{"class":271},[202,1276,1036],{"class":227},[202,1278,1044],{"class":271},[202,1280,1281],{"class":267},"|",[202,1283,1284],{"class":227}," None",[202,1286,983],{"class":271},[202,1288,1290,1293,1295,1298,1301,1303,1305,1308],{"class":105,"line":1289},45,[202,1291,1292],{"class":271},"    body ",[202,1294,275],{"class":267},[202,1296,1297],{"class":271}," raw.removeprefix(",[202,1299,1300],{"class":267},"f",[202,1302,1217],{"class":211},[202,1304,1220],{"class":227},[202,1306,1307],{"class":211},"_\"",[202,1309,818],{"class":271},[202,1311,1313,1316,1319,1322],{"class":105,"line":1312},46,[202,1314,1315],{"class":267},"    if",[202,1317,1318],{"class":271}," body ",[202,1320,1321],{"class":267},"==",[202,1323,1324],{"class":271}," raw:\n",[202,1326,1328,1331],{"class":105,"line":1327},47,[202,1329,1330],{"class":267},"        return",[202,1332,1333],{"class":227}," None\n",[202,1335,1337,1340,1342,1345,1348],{"class":105,"line":1336},48,[202,1338,1339],{"class":271},"    key_id, _, secret ",[202,1341,275],{"class":267},[202,1343,1344],{"class":271}," body.partition(",[202,1346,1347],{"class":211},"\"_\"",[202,1349,818],{"class":271},[202,1351,1353,1355,1358,1361,1364,1367,1370,1373],{"class":105,"line":1352},49,[202,1354,1211],{"class":267},[202,1356,1357],{"class":271}," (key_id, secret) ",[202,1359,1360],{"class":267},"if",[202,1362,1363],{"class":271}," key_id ",[202,1365,1366],{"class":267},"and",[202,1368,1369],{"class":271}," secret ",[202,1371,1372],{"class":267},"else",[202,1374,1333],{"class":227},[202,1376,1378],{"class":105,"line":1377},50,[202,1379,754],{"emptyLinePlaceholder":753},[202,1381,1383],{"class":105,"line":1382},51,[202,1384,754],{"emptyLinePlaceholder":753},[202,1386,1388,1390,1393,1395,1397],{"class":105,"line":1387},52,[202,1389,1065],{"class":267},[202,1391,1392],{"class":207}," verify_api_key",[202,1394,1265],{"class":271},[202,1396,1036],{"class":227},[202,1398,1399],{"class":271},") -> Principal:\n",[202,1401,1403,1406,1408],{"class":105,"line":1402},53,[202,1404,1405],{"class":271},"    parsed ",[202,1407,275],{"class":267},[202,1409,1410],{"class":271}," parse_api_key(raw)\n",[202,1412,1414,1416,1419,1422,1424],{"class":105,"line":1413},54,[202,1415,1315],{"class":267},[202,1417,1418],{"class":271}," parsed ",[202,1420,1421],{"class":267},"is",[202,1423,1284],{"class":227},[202,1425,983],{"class":271},[202,1427,1429,1432,1434,1436,1438,1440,1442],{"class":105,"line":1428},55,[202,1430,1431],{"class":271},"        bcrypt.verify(",[202,1433,935],{"class":211},[202,1435,938],{"class":267},[202,1437,304],{"class":227},[202,1439,812],{"class":271},[202,1441,917],{"class":227},[202,1443,818],{"class":271},[202,1445,1447,1450,1453,1456,1458,1461],{"class":105,"line":1446},56,[202,1448,1449],{"class":267},"        raise",[202,1451,1452],{"class":271}," HTTPException(status.",[202,1454,1455],{"class":227},"HTTP_401_UNAUTHORIZED",[202,1457,812],{"class":271},[202,1459,1460],{"class":211},"\"Malformed API key\"",[202,1462,818],{"class":271},[202,1464,1466,1469,1471],{"class":105,"line":1465},57,[202,1467,1468],{"class":271},"    key_id, secret ",[202,1470,275],{"class":267},[202,1472,1473],{"class":271}," parsed\n",[202,1475,1477,1480,1482,1485],{"class":105,"line":1476},58,[202,1478,1479],{"class":271},"    row ",[202,1481,275],{"class":267},[202,1483,1484],{"class":227}," KEY_ROWS",[202,1486,1487],{"class":271},".get(key_id)\n",[202,1489,1491,1493,1496,1498,1500,1503,1506,1509],{"class":105,"line":1490},59,[202,1492,1315],{"class":267},[202,1494,1495],{"class":271}," row ",[202,1497,1421],{"class":267},[202,1499,1284],{"class":227},[202,1501,1502],{"class":267}," or",[202,1504,1505],{"class":271}," row[",[202,1507,1508],{"class":211},"\"revoked\"",[202,1510,1511],{"class":271},"]:\n",[202,1513,1515,1517,1519,1521,1523,1525,1527],{"class":105,"line":1514},60,[202,1516,1431],{"class":271},[202,1518,935],{"class":211},[202,1520,938],{"class":267},[202,1522,304],{"class":227},[202,1524,812],{"class":271},[202,1526,917],{"class":227},[202,1528,818],{"class":271},[202,1530,1532,1534,1536,1538,1540,1543],{"class":105,"line":1531},61,[202,1533,1449],{"class":267},[202,1535,1452],{"class":271},[202,1537,1455],{"class":227},[202,1539,812],{"class":271},[202,1541,1542],{"class":211},"\"Invalid API key\"",[202,1544,818],{"class":271},[202,1546,1548,1550,1553,1556,1558,1560,1563,1566],{"class":105,"line":1547},62,[202,1549,1315],{"class":267},[202,1551,1552],{"class":267}," not",[202,1554,1555],{"class":271}," bcrypt.verify(secret ",[202,1557,1162],{"class":267},[202,1559,304],{"class":227},[202,1561,1562],{"class":271},", row[",[202,1564,1565],{"class":211},"\"hash\"",[202,1567,1568],{"class":271},"]):\n",[202,1570,1572,1574,1576,1578,1580,1582],{"class":105,"line":1571},63,[202,1573,1449],{"class":267},[202,1575,1452],{"class":271},[202,1577,1455],{"class":227},[202,1579,812],{"class":271},[202,1581,1542],{"class":211},[202,1583,818],{"class":271},[202,1585,1587,1589,1592,1594,1596,1599,1602,1604,1607,1610,1613,1616,1618,1620,1623],{"class":105,"line":1586},64,[202,1588,1211],{"class":267},[202,1590,1591],{"class":271}," Principal(",[202,1593,1229],{"class":882},[202,1595,275],{"class":267},[202,1597,1598],{"class":271},"key_id, ",[202,1600,1601],{"class":882},"account_id",[202,1603,275],{"class":267},[202,1605,1606],{"class":271},"row[",[202,1608,1609],{"class":211},"\"account_id\"",[202,1611,1612],{"class":271},"], ",[202,1614,1615],{"class":882},"plan",[202,1617,275],{"class":267},[202,1619,1606],{"class":271},[202,1621,1622],{"class":211},"\"plan\"",[202,1624,1625],{"class":271},"])\n",[202,1627,1629],{"class":105,"line":1628},65,[202,1630,754],{"emptyLinePlaceholder":753},[202,1632,1634],{"class":105,"line":1633},66,[202,1635,754],{"emptyLinePlaceholder":753},[202,1637,1639,1642,1645,1648,1650,1652,1655,1657,1659],{"class":105,"line":1638},67,[202,1640,1641],{"class":267},"async",[202,1643,1644],{"class":267}," def",[202,1646,1647],{"class":207}," require_api_key",[202,1649,1265],{"class":271},[202,1651,1036],{"class":227},[202,1653,1654],{"class":267}," |",[202,1656,1284],{"class":227},[202,1658,803],{"class":267},[202,1660,1661],{"class":271}," Depends(api_key_header)) -> Principal:\n",[202,1663,1665,1667,1669],{"class":105,"line":1664},68,[202,1666,1315],{"class":267},[202,1668,1552],{"class":267},[202,1670,1324],{"class":271},[202,1672,1674,1676,1678,1680,1682,1685],{"class":105,"line":1673},69,[202,1675,1449],{"class":267},[202,1677,1452],{"class":271},[202,1679,1455],{"class":227},[202,1681,812],{"class":271},[202,1683,1684],{"class":211},"\"Missing X-API-Key header\"",[202,1686,818],{"class":271},[202,1688,1690,1692],{"class":105,"line":1689},70,[202,1691,1211],{"class":267},[202,1693,1694],{"class":271}," verify_api_key(raw)\n",[14,1696,1697],{},"The decoy hash is worth understanding. Without it, an unknown key id returns in microseconds while a known one takes tens of milliseconds, and an attacker can enumerate valid key ids purely by timing the response. Burning one equivalent bcrypt call on the failure path collapses that signal. It is three lines and it closes a real oracle.",[14,1699,1700,1701,1703,1704,453],{},"Attach the dependency to a route and you have plan-aware access in one line. The returned ",[199,1702,1615],{}," is what you feed your quota check — exactly the hook a monetization tier needs, covered in ",[18,1705,1707],{"href":1706},"\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",[192,1709,1711],{"className":711,"code":1710,"language":713,"meta":197,"style":197},"# main.py (excerpt)\nfrom fastapi import Depends, FastAPI\n\nfrom apikeys import Principal, require_api_key\n\napp = FastAPI()\n\n\n@app.get(\"\u002Fv1\u002Fdata\")\nasync def get_data(principal: Principal = Depends(require_api_key)) -> dict:\n    return {\"plan\": principal.plan, \"account\": principal.account_id, \"data\": [1, 2, 3]}\n",[199,1712,1713,1718,1729,1733,1745,1749,1759,1763,1767,1779,1800],{"__ignoreMap":197},[202,1714,1715],{"class":105,"line":204},[202,1716,1717],{"class":296},"# main.py (excerpt)\n",[202,1719,1720,1722,1724,1726],{"class":105,"line":231},[202,1721,740],{"class":267},[202,1723,761],{"class":271},[202,1725,725],{"class":267},[202,1727,1728],{"class":271}," Depends, FastAPI\n",[202,1730,1731],{"class":105,"line":325},[202,1732,754],{"emptyLinePlaceholder":753},[202,1734,1735,1737,1740,1742],{"class":105,"line":338},[202,1736,740],{"class":267},[202,1738,1739],{"class":271}," apikeys ",[202,1741,725],{"class":267},[202,1743,1744],{"class":271}," Principal, require_api_key\n",[202,1746,1747],{"class":105,"line":354},[202,1748,754],{"emptyLinePlaceholder":753},[202,1750,1751,1754,1756],{"class":105,"line":367},[202,1752,1753],{"class":271},"app ",[202,1755,275],{"class":267},[202,1757,1758],{"class":271}," FastAPI()\n",[202,1760,1761],{"class":105,"line":380},[202,1762,754],{"emptyLinePlaceholder":753},[202,1764,1765],{"class":105,"line":393},[202,1766,754],{"emptyLinePlaceholder":753},[202,1768,1769,1772,1774,1777],{"class":105,"line":793},[202,1770,1771],{"class":207},"@app.get",[202,1773,961],{"class":271},[202,1775,1776],{"class":211},"\"\u002Fv1\u002Fdata\"",[202,1778,818],{"class":271},[202,1780,1781,1783,1785,1788,1791,1793,1796,1798],{"class":105,"line":798},[202,1782,1641],{"class":267},[202,1784,1644],{"class":267},[202,1786,1787],{"class":207}," get_data",[202,1789,1790],{"class":271},"(principal: Principal ",[202,1792,275],{"class":267},[202,1794,1795],{"class":271}," Depends(require_api_key)) -> ",[202,1797,1041],{"class":227},[202,1799,983],{"class":271},[202,1801,1802,1804,1807,1809,1812,1815,1818,1821,1824,1827,1829,1832,1834,1837],{"class":105,"line":821},[202,1803,1211],{"class":267},[202,1805,1806],{"class":271}," {",[202,1808,1622],{"class":211},[202,1810,1811],{"class":271},": principal.plan, ",[202,1813,1814],{"class":211},"\"account\"",[202,1816,1817],{"class":271},": principal.account_id, ",[202,1819,1820],{"class":211},"\"data\"",[202,1822,1823],{"class":271},": [",[202,1825,1826],{"class":227},"1",[202,1828,812],{"class":271},[202,1830,1831],{"class":227},"2",[202,1833,812],{"class":271},[202,1835,1836],{"class":227},"3",[202,1838,1839],{"class":271},"]}\n",[14,1841,1842,1843,1846,1847,1850,1851,453],{},"Two schema details save you pain later. Store ",[199,1844,1845],{},"last_used_at"," on the key row and update it lazily — a daily batch, not a write per request — because the first question in every rotation conversation is \"is this key still in use?\". And store a ",[199,1848,1849],{},"label"," the customer sets themselves (\"prod worker\", \"Zapier\"), because a customer with four unlabelled keys will never revoke any of them. The full dual-key overlap procedure lives in ",[18,1852,1854],{"href":1853},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F","rotating API keys without downtime",[181,1856],{},[184,1858,1860],{"id":1859},"step-2-jwts-with-expiry-scopes-and-clock-skew","Step 2: JWTs with expiry, scopes and clock skew",[14,1862,1863],{},"User sessions need short-lived, self-describing tokens. A JWT carries the subject, an expiry, and a list of scopes — your API verifies the signature locally with no database round-trip, which is the entire reason to use one. Always set an expiry, always pin the algorithm on decode, and always require the claims you depend on rather than trusting that they are present.",[192,1865,1867],{"className":711,"code":1866,"language":713,"meta":197,"style":197},"# jwttoken.py\nimport os\nimport time\nimport uuid\n\nimport jwt  # PyJWT\nfrom fastapi import HTTPException, status\n\nJWT_SECRET = os.getenv(\"JWT_SECRET\", \"\")\nJWT_ALGORITHM = os.getenv(\"JWT_ALGORITHM\", \"HS256\")\nJWT_TTL_SECONDS = int(os.getenv(\"JWT_TTL_SECONDS\", \"900\"))\nJWT_ISSUER = os.getenv(\"JWT_ISSUER\", \"https:\u002F\u002Fapi.localhost\")\nJWT_AUDIENCE = os.getenv(\"JWT_AUDIENCE\", \"api\")\nJWT_LEEWAY_SECONDS = int(os.getenv(\"JWT_LEEWAY_SECONDS\", \"30\"))\n\n_UNAUTHORIZED_HEADERS = {\"WWW-Authenticate\": \"Bearer\"}\n\n\ndef create_access_token(sub: str, scopes: list[str]) -> str:\n    now = int(time.time())\n    payload = {\n        \"sub\": sub,\n        \"scopes\": scopes,\n        \"iss\": JWT_ISSUER,\n        \"aud\": JWT_AUDIENCE,\n        \"jti\": uuid.uuid4().hex,\n        \"iat\": now,\n        \"exp\": now + JWT_TTL_SECONDS,\n    }\n    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)\n\n\ndef decode_access_token(token: str) -> dict:\n    try:\n        # Pinning algorithms=[...] is what blocks the alg=none attack.\n        return jwt.decode(\n            token,\n            JWT_SECRET,\n            algorithms=[JWT_ALGORITHM],\n            audience=JWT_AUDIENCE,\n            issuer=JWT_ISSUER,\n            leeway=JWT_LEEWAY_SECONDS,\n            options={\"require\": [\"exp\", \"iat\", \"sub\"]},\n        )\n    except jwt.ExpiredSignatureError:\n        raise HTTPException(\n            status.HTTP_401_UNAUTHORIZED, \"Token expired\", _UNAUTHORIZED_HEADERS\n        )\n    except jwt.InvalidTokenError:\n        raise HTTPException(\n            status.HTTP_401_UNAUTHORIZED, \"Invalid token\", _UNAUTHORIZED_HEADERS\n        )\n",[199,1868,1869,1874,1880,1887,1894,1898,1908,1919,1923,1941,1960,1980,1998,2016,2037,2041,2061,2065,2069,2093,2105,2114,2122,2130,2141,2152,2160,2168,2182,2186,2206,2210,2214,2232,2239,2244,2251,2256,2263,2278,2289,2300,2311,2341,2346,2354,2361,2378,2382,2389,2395,2410],{"__ignoreMap":197},[202,1870,1871],{"class":105,"line":204},[202,1872,1873],{"class":296},"# jwttoken.py\n",[202,1875,1876,1878],{"class":105,"line":231},[202,1877,725],{"class":267},[202,1879,728],{"class":271},[202,1881,1882,1884],{"class":105,"line":325},[202,1883,725],{"class":267},[202,1885,1886],{"class":271}," time\n",[202,1888,1889,1891],{"class":105,"line":338},[202,1890,725],{"class":267},[202,1892,1893],{"class":271}," uuid\n",[202,1895,1896],{"class":105,"line":354},[202,1897,754],{"emptyLinePlaceholder":753},[202,1899,1900,1902,1905],{"class":105,"line":367},[202,1901,725],{"class":267},[202,1903,1904],{"class":271}," jwt  ",[202,1906,1907],{"class":296},"# PyJWT\n",[202,1909,1910,1912,1914,1916],{"class":105,"line":380},[202,1911,740],{"class":267},[202,1913,761],{"class":271},[202,1915,725],{"class":267},[202,1917,1918],{"class":271}," HTTPException, status\n",[202,1920,1921],{"class":105,"line":393},[202,1922,754],{"emptyLinePlaceholder":753},[202,1924,1925,1928,1930,1932,1935,1937,1939],{"class":105,"line":793},[202,1926,1927],{"class":227},"JWT_SECRET",[202,1929,803],{"class":267},[202,1931,806],{"class":271},[202,1933,1934],{"class":211},"\"JWT_SECRET\"",[202,1936,812],{"class":271},[202,1938,815],{"class":211},[202,1940,818],{"class":271},[202,1942,1943,1946,1948,1950,1953,1955,1958],{"class":105,"line":798},[202,1944,1945],{"class":227},"JWT_ALGORITHM",[202,1947,803],{"class":267},[202,1949,806],{"class":271},[202,1951,1952],{"class":211},"\"JWT_ALGORITHM\"",[202,1954,812],{"class":271},[202,1956,1957],{"class":211},"\"HS256\"",[202,1959,818],{"class":271},[202,1961,1962,1965,1967,1969,1971,1974,1976,1978],{"class":105,"line":821},[202,1963,1964],{"class":227},"JWT_TTL_SECONDS",[202,1966,803],{"class":267},[202,1968,849],{"class":227},[202,1970,852],{"class":271},[202,1972,1973],{"class":211},"\"JWT_TTL_SECONDS\"",[202,1975,812],{"class":271},[202,1977,348],{"class":211},[202,1979,863],{"class":271},[202,1981,1982,1984,1986,1988,1991,1993,1996],{"class":105,"line":841},[202,1983,413],{"class":227},[202,1985,803],{"class":267},[202,1987,806],{"class":271},[202,1989,1990],{"class":211},"\"JWT_ISSUER\"",[202,1992,812],{"class":271},[202,1994,1995],{"class":211},"\"https:\u002F\u002Fapi.localhost\"",[202,1997,818],{"class":271},[202,1999,2000,2002,2004,2006,2009,2011,2014],{"class":105,"line":866},[202,2001,417],{"class":227},[202,2003,803],{"class":267},[202,2005,806],{"class":271},[202,2007,2008],{"class":211},"\"JWT_AUDIENCE\"",[202,2010,812],{"class":271},[202,2012,2013],{"class":211},"\"api\"",[202,2015,818],{"class":271},[202,2017,2018,2021,2023,2025,2027,2030,2032,2035],{"class":105,"line":871},[202,2019,2020],{"class":227},"JWT_LEEWAY_SECONDS",[202,2022,803],{"class":267},[202,2024,849],{"class":227},[202,2026,852],{"class":271},[202,2028,2029],{"class":211},"\"JWT_LEEWAY_SECONDS\"",[202,2031,812],{"class":271},[202,2033,2034],{"class":211},"\"30\"",[202,2036,863],{"class":271},[202,2038,2039],{"class":105,"line":903},[202,2040,754],{"emptyLinePlaceholder":753},[202,2042,2043,2046,2048,2050,2053,2055,2058],{"class":105,"line":908},[202,2044,2045],{"class":227},"_UNAUTHORIZED_HEADERS",[202,2047,803],{"class":267},[202,2049,1806],{"class":271},[202,2051,2052],{"class":211},"\"WWW-Authenticate\"",[202,2054,1194],{"class":271},[202,2056,2057],{"class":211},"\"Bearer\"",[202,2059,2060],{"class":271},"}\n",[202,2062,2063],{"class":105,"line":914},[202,2064,754],{"emptyLinePlaceholder":753},[202,2066,2067],{"class":105,"line":945},[202,2068,754],{"emptyLinePlaceholder":753},[202,2070,2071,2073,2076,2079,2081,2084,2086,2089,2091],{"class":105,"line":950},[202,2072,1065],{"class":267},[202,2074,2075],{"class":207}," create_access_token",[202,2077,2078],{"class":271},"(sub: ",[202,2080,1036],{"class":227},[202,2082,2083],{"class":271},", scopes: list[",[202,2085,1036],{"class":227},[202,2087,2088],{"class":271},"]) -> ",[202,2090,1036],{"class":227},[202,2092,983],{"class":271},[202,2094,2095,2098,2100,2102],{"class":105,"line":955},[202,2096,2097],{"class":271},"    now ",[202,2099,275],{"class":267},[202,2101,849],{"class":227},[202,2103,2104],{"class":271},"(time.time())\n",[202,2106,2107,2110,2112],{"class":105,"line":974},[202,2108,2109],{"class":271},"    payload ",[202,2111,275],{"class":267},[202,2113,1141],{"class":271},[202,2115,2116,2119],{"class":105,"line":986},[202,2117,2118],{"class":211},"        \"sub\"",[202,2120,2121],{"class":271},": sub,\n",[202,2123,2124,2127],{"class":105,"line":995},[202,2125,2126],{"class":211},"        \"scopes\"",[202,2128,2129],{"class":271},": scopes,\n",[202,2131,2132,2135,2137,2139],{"class":105,"line":1003},[202,2133,2134],{"class":211},"        \"iss\"",[202,2136,1194],{"class":271},[202,2138,413],{"class":227},[202,2140,1199],{"class":271},[202,2142,2143,2146,2148,2150],{"class":105,"line":1011},[202,2144,2145],{"class":211},"        \"aud\"",[202,2147,1194],{"class":271},[202,2149,417],{"class":227},[202,2151,1199],{"class":271},[202,2153,2154,2157],{"class":105,"line":1016},[202,2155,2156],{"class":211},"        \"jti\"",[202,2158,2159],{"class":271},": uuid.uuid4().hex,\n",[202,2161,2162,2165],{"class":105,"line":1021},[202,2163,2164],{"class":211},"        \"iat\"",[202,2166,2167],{"class":271},": now,\n",[202,2169,2170,2173,2176,2178,2180],{"class":105,"line":1027},[202,2171,2172],{"class":211},"        \"exp\"",[202,2174,2175],{"class":271},": now ",[202,2177,1162],{"class":267},[202,2179,343],{"class":227},[202,2181,1199],{"class":271},[202,2183,2184],{"class":105,"line":1052},[202,2185,1205],{"class":271},[202,2187,2188,2190,2193,2195,2197,2200,2202,2204],{"class":105,"line":1057},[202,2189,1211],{"class":267},[202,2191,2192],{"class":271}," jwt.encode(payload, ",[202,2194,1927],{"class":227},[202,2196,812],{"class":271},[202,2198,2199],{"class":882},"algorithm",[202,2201,275],{"class":267},[202,2203,1945],{"class":227},[202,2205,818],{"class":271},[202,2207,2208],{"class":105,"line":1062},[202,2209,754],{"emptyLinePlaceholder":753},[202,2211,2212],{"class":105,"line":1093},[202,2213,754],{"emptyLinePlaceholder":753},[202,2215,2216,2218,2221,2224,2226,2228,2230],{"class":105,"line":1099},[202,2217,1065],{"class":267},[202,2219,2220],{"class":207}," decode_access_token",[202,2222,2223],{"class":271},"(token: ",[202,2225,1036],{"class":227},[202,2227,1086],{"class":271},[202,2229,1041],{"class":227},[202,2231,983],{"class":271},[202,2233,2234,2237],{"class":105,"line":1114},[202,2235,2236],{"class":267},"    try",[202,2238,983],{"class":271},[202,2240,2241],{"class":105,"line":1130},[202,2242,2243],{"class":296},"        # Pinning algorithms=[...] is what blocks the alg=none attack.\n",[202,2245,2246,2248],{"class":105,"line":1144},[202,2247,1330],{"class":267},[202,2249,2250],{"class":271}," jwt.decode(\n",[202,2252,2253],{"class":105,"line":1170},[202,2254,2255],{"class":271},"            token,\n",[202,2257,2258,2261],{"class":105,"line":1179},[202,2259,2260],{"class":227},"            JWT_SECRET",[202,2262,1199],{"class":271},[202,2264,2265,2268,2270,2273,2275],{"class":105,"line":1188},[202,2266,2267],{"class":882},"            algorithms",[202,2269,275],{"class":267},[202,2271,2272],{"class":271},"[",[202,2274,1945],{"class":227},[202,2276,2277],{"class":271},"],\n",[202,2279,2280,2283,2285,2287],{"class":105,"line":1202},[202,2281,2282],{"class":882},"            audience",[202,2284,275],{"class":267},[202,2286,417],{"class":227},[202,2288,1199],{"class":271},[202,2290,2291,2294,2296,2298],{"class":105,"line":1208},[202,2292,2293],{"class":882},"            issuer",[202,2295,275],{"class":267},[202,2297,413],{"class":227},[202,2299,1199],{"class":271},[202,2301,2302,2305,2307,2309],{"class":105,"line":1247},[202,2303,2304],{"class":882},"            leeway",[202,2306,275],{"class":267},[202,2308,2020],{"class":227},[202,2310,1199],{"class":271},[202,2312,2313,2316,2318,2320,2323,2325,2328,2330,2333,2335,2338],{"class":105,"line":1252},[202,2314,2315],{"class":882},"            options",[202,2317,275],{"class":267},[202,2319,1226],{"class":271},[202,2321,2322],{"class":211},"\"require\"",[202,2324,1823],{"class":271},[202,2326,2327],{"class":211},"\"exp\"",[202,2329,812],{"class":271},[202,2331,2332],{"class":211},"\"iat\"",[202,2334,812],{"class":271},[202,2336,2337],{"class":211},"\"sub\"",[202,2339,2340],{"class":271},"]},\n",[202,2342,2343],{"class":105,"line":1257},[202,2344,2345],{"class":271},"        )\n",[202,2347,2348,2351],{"class":105,"line":1289},[202,2349,2350],{"class":267},"    except",[202,2352,2353],{"class":271}," jwt.ExpiredSignatureError:\n",[202,2355,2356,2358],{"class":105,"line":1312},[202,2357,1449],{"class":267},[202,2359,2360],{"class":271}," HTTPException(\n",[202,2362,2363,2366,2368,2370,2373,2375],{"class":105,"line":1327},[202,2364,2365],{"class":271},"            status.",[202,2367,1455],{"class":227},[202,2369,812],{"class":271},[202,2371,2372],{"class":211},"\"Token expired\"",[202,2374,812],{"class":271},[202,2376,2377],{"class":227},"_UNAUTHORIZED_HEADERS\n",[202,2379,2380],{"class":105,"line":1336},[202,2381,2345],{"class":271},[202,2383,2384,2386],{"class":105,"line":1352},[202,2385,2350],{"class":267},[202,2387,2388],{"class":271}," jwt.InvalidTokenError:\n",[202,2390,2391,2393],{"class":105,"line":1377},[202,2392,1449],{"class":267},[202,2394,2360],{"class":271},[202,2396,2397,2399,2401,2403,2406,2408],{"class":105,"line":1382},[202,2398,2365],{"class":271},[202,2400,1455],{"class":227},[202,2402,812],{"class":271},[202,2404,2405],{"class":211},"\"Invalid token\"",[202,2407,812],{"class":271},[202,2409,2377],{"class":227},[202,2411,2412],{"class":105,"line":1387},[202,2413,2345],{"class":271},[14,2415,2416,2417,2420,2421,2424],{},"The ",[199,2418,2419],{},"leeway"," argument is the one people discover through support tickets. Container clocks drift, and a client whose clock runs forty seconds ahead of your server will present a token your server considers not-yet-valid. Thirty seconds of leeway absorbs ordinary drift without meaningfully extending a token's life. The ",[199,2422,2423],{},"jti"," claim earns its place too: it is the handle you put on a denylist when you need to kill one specific session before it expires.",[14,2426,2427],{},"Now the lifetime question, which is a product decision disguised as a config value. Short access tokens plus a long refresh token give you a revocation window measured in minutes while keeping users logged in for weeks. Fifteen minutes is the sweet spot for a dashboard; go to five if you handle payment data, and never go past an hour on a token you cannot revoke.",[32,2429,41,2434,41,2437,41,2440,41,2447,41,2449,41,2452,41,2456,41,2460,41,2463,41,2466,41,2470,41,2473,41,2477,41,2480,41,2484,41,2488,41,2490,41,2492,41,2494,41,2499,41,2504,41,2506],{"viewBox":2430,"role":35,"ariaLabelledBy":2431,"xmlns":39,"style":40},"0 0 720 240",[2432,2433],"apiauth-life-t","apiauth-life-d",[43,2435,2436],{"id":2432},"Access token and refresh token lifetimes",[47,2438,2439],{"id":2433},"A timeline showing login issuing a token pair, the 15-minute access token expiring, a silent refresh minting a new access token, and the 30-day refresh window ending with a forced re-login.",[51,2441,53,2442,41],{},[55,2443,63,2445,53],{"id":2444,"viewBox":58,"refX":59,"refY":60,"markerWidth":61,"markerHeight":61,"orient":62},"apiauth-life-arrow",[65,2446],{"d":67,"fill":68},[70,2448],{"x":72,"y":72,"width":73,"height":156,"fill":75},[77,2450,2451],{"x":700,"y":696,"fill":68,"style":117},"Access token lifetime vs refresh window",[77,2453,2455],{"x":518,"y":2454,"fill":96,"style":122},"76","Login",[77,2457,2459],{"x":518,"y":2458,"fill":68,"style":122},"94","token pair issued",[77,2461,2462],{"x":468,"y":2454,"fill":96,"style":122},"Access token",[77,2464,2465],{"x":468,"y":2458,"fill":68,"style":122},"expires (900 s)",[77,2467,2469],{"x":2468,"y":2454,"fill":96,"style":122},"460","Silent refresh",[77,2471,2472],{"x":2468,"y":2458,"fill":68,"style":122},"new 15-min token",[77,2474,2476],{"x":2475,"y":2454,"fill":96,"style":122},"620","Refresh expires",[77,2478,2479],{"x":2475,"y":2458,"fill":68,"style":122},"user logs in again",[105,2481],{"x1":639,"y1":85,"x2":2482,"y2":85,"stroke":68,"style":2483},"672","stroke-width:2;marker-end:url(#apiauth-life-arrow);",[2485,2486],"circle",{"cx":518,"cy":85,"r":697,"fill":75,"stroke":90,"style":2487},"stroke-width:3;",[2485,2489],{"cx":468,"cy":85,"r":697,"fill":75,"stroke":171,"style":2487},[2485,2491],{"cx":2468,"cy":85,"r":697,"fill":75,"stroke":113,"style":2487},[2485,2493],{"cx":2475,"cy":85,"r":697,"fill":75,"stroke":137,"style":2487},[70,2495],{"x":518,"y":2496,"width":2497,"height":2498,"rx":519,"fill":89,"stroke":113,"style":91},"140","530","16",[77,2500,2503],{"x":2501,"y":2502,"fill":96,"style":122},"355","176","refresh token valid - 30 days",[70,2505],{"x":518,"y":654,"width":654,"height":2498,"rx":519,"fill":89,"stroke":90,"style":91},[77,2507,2509],{"x":2508,"y":165,"fill":96,"style":122},"185","access token - 15 min",[14,2511,2512,2513,2516,2517,2520,2521,2523],{},"One algorithm note before moving on. ",[199,2514,2515],{},"HS256"," uses one shared secret to sign and verify, which is perfect while a single service does both. The moment a second service needs to verify tokens you signed, switch to ",[199,2518,2519],{},"RS256",": you hand out the public key freely and keep the private key in one place, so a compromised read-only service cannot mint tokens. Migrating is a rolling deploy — publish the public key, accept both algorithms for one release, then drop ",[199,2522,2515],{}," — and it is far easier before you have downstream consumers than after.",[181,2525],{},[184,2527,2529],{"id":2528},"step-3-the-oauth2-password-and-bearer-flow","Step 3: The OAuth2 password and bearer flow",[14,2531,2532,2533,2536,2537,2540,2541,2545,2546,2549,2550,2553],{},"FastAPI's ",[199,2534,2535],{},"OAuth2PasswordBearer"," wires the login form, the ",[199,2538,2539],{},"Authorization: Bearer"," header, and the Swagger \"Authorize\" button together, which is why your interactive docs suddenly become usable for authenticated endpoints — a detail worth pairing with ",[18,2542,2544],{"href":2543},"\u002Fgetting-started-with-python-apis-for-builders\u002Fdocumenting-apis-with-openapi\u002F","documenting APIs with OpenAPI",". A ",[199,2547,2548],{},"\u002Fauth\u002Ftoken"," endpoint verifies the user's password hash and returns a JWT; a ",[199,2551,2552],{},"require_scopes"," dependency then enforces fine-grained permissions per route.",[192,2555,2557],{"className":711,"code":2556,"language":713,"meta":197,"style":197},"# auth.py\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom fastapi.security import (\n    OAuth2PasswordBearer,\n    OAuth2PasswordRequestForm,\n    SecurityScopes,\n)\nfrom passlib.hash import bcrypt\n\nfrom jwttoken import create_access_token, decode_access_token\n\nrouter = APIRouter()\noauth2_scheme = OAuth2PasswordBearer(\n    tokenUrl=\"\u002Fauth\u002Ftoken\",\n    scopes={\"read\": \"Read data\", \"write\": \"Modify data\"},\n)\n\n# Demo user; replace with a DB lookup of hashed passwords.\nUSERS = {\"alice\": {\"hash\": bcrypt.hash(\"s3cret\"), \"scopes\": [\"read\", \"write\"]}}\n\n\n@router.post(\"\u002Fauth\u002Ftoken\")\nasync def login(form: OAuth2PasswordRequestForm = Depends()) -> dict:\n    user = USERS.get(form.username)\n    if not user or not bcrypt.verify(form.password, user[\"hash\"]):\n        raise HTTPException(\n            status.HTTP_401_UNAUTHORIZED,\n            \"Incorrect username or password\",\n            {\"WWW-Authenticate\": \"Bearer\"},\n        )\n    granted = [s for s in form.scopes if s in user[\"scopes\"]]\n    return {\n        \"access_token\": create_access_token(form.username, granted),\n        \"token_type\": \"bearer\",\n    }\n\n\nasync def require_scopes(\n    security: SecurityScopes, token: str = Depends(oauth2_scheme)\n) -> dict:\n    payload = decode_access_token(token)\n    token_scopes = set(payload.get(\"scopes\", []))\n    for scope in security.scopes:\n        if scope not in token_scopes:\n            raise HTTPException(\n                status.HTTP_403_FORBIDDEN,\n                f\"Missing scope: {scope}\",\n                {\"WWW-Authenticate\": f'Bearer scope=\"{scope}\"'},\n            )\n    return payload\n",[199,2558,2559,2564,2575,2586,2591,2596,2601,2605,2615,2619,2631,2635,2645,2655,2667,2697,2701,2705,2710,2750,2754,2758,2769,2790,2803,2824,2830,2838,2845,2858,2862,2898,2904,2912,2924,2928,2932,2936,2948,2960,2968,2977,2995,3008,3024,3031,3041,3060,3085,3090],{"__ignoreMap":197},[202,2560,2561],{"class":105,"line":204},[202,2562,2563],{"class":296},"# auth.py\n",[202,2565,2566,2568,2570,2572],{"class":105,"line":231},[202,2567,740],{"class":267},[202,2569,761],{"class":271},[202,2571,725],{"class":267},[202,2573,2574],{"class":271}," APIRouter, Depends, HTTPException, status\n",[202,2576,2577,2579,2581,2583],{"class":105,"line":325},[202,2578,740],{"class":267},[202,2580,773],{"class":271},[202,2582,725],{"class":267},[202,2584,2585],{"class":271}," (\n",[202,2587,2588],{"class":105,"line":338},[202,2589,2590],{"class":271},"    OAuth2PasswordBearer,\n",[202,2592,2593],{"class":105,"line":354},[202,2594,2595],{"class":271},"    OAuth2PasswordRequestForm,\n",[202,2597,2598],{"class":105,"line":367},[202,2599,2600],{"class":271},"    SecurityScopes,\n",[202,2602,2603],{"class":105,"line":380},[202,2604,818],{"class":271},[202,2606,2607,2609,2611,2613],{"class":105,"line":393},[202,2608,740],{"class":267},[202,2610,785],{"class":271},[202,2612,725],{"class":267},[202,2614,790],{"class":271},[202,2616,2617],{"class":105,"line":793},[202,2618,754],{"emptyLinePlaceholder":753},[202,2620,2621,2623,2626,2628],{"class":105,"line":798},[202,2622,740],{"class":267},[202,2624,2625],{"class":271}," jwttoken ",[202,2627,725],{"class":267},[202,2629,2630],{"class":271}," create_access_token, decode_access_token\n",[202,2632,2633],{"class":105,"line":821},[202,2634,754],{"emptyLinePlaceholder":753},[202,2636,2637,2640,2642],{"class":105,"line":841},[202,2638,2639],{"class":271},"router ",[202,2641,275],{"class":267},[202,2643,2644],{"class":271}," APIRouter()\n",[202,2646,2647,2650,2652],{"class":105,"line":866},[202,2648,2649],{"class":271},"oauth2_scheme ",[202,2651,275],{"class":267},[202,2653,2654],{"class":271}," OAuth2PasswordBearer(\n",[202,2656,2657,2660,2662,2665],{"class":105,"line":871},[202,2658,2659],{"class":882},"    tokenUrl",[202,2661,275],{"class":267},[202,2663,2664],{"class":211},"\"\u002Fauth\u002Ftoken\"",[202,2666,1199],{"class":271},[202,2668,2669,2672,2674,2676,2679,2681,2684,2686,2689,2691,2694],{"class":105,"line":903},[202,2670,2671],{"class":882},"    scopes",[202,2673,275],{"class":267},[202,2675,1226],{"class":271},[202,2677,2678],{"class":211},"\"read\"",[202,2680,1194],{"class":271},[202,2682,2683],{"class":211},"\"Read data\"",[202,2685,812],{"class":271},[202,2687,2688],{"class":211},"\"write\"",[202,2690,1194],{"class":271},[202,2692,2693],{"class":211},"\"Modify data\"",[202,2695,2696],{"class":271},"},\n",[202,2698,2699],{"class":105,"line":908},[202,2700,818],{"class":271},[202,2702,2703],{"class":105,"line":914},[202,2704,754],{"emptyLinePlaceholder":753},[202,2706,2707],{"class":105,"line":945},[202,2708,2709],{"class":296},"# Demo user; replace with a DB lookup of hashed passwords.\n",[202,2711,2712,2715,2717,2719,2722,2725,2727,2730,2733,2736,2739,2741,2743,2745,2747],{"class":105,"line":950},[202,2713,2714],{"class":227},"USERS",[202,2716,803],{"class":267},[202,2718,1806],{"class":271},[202,2720,2721],{"class":211},"\"alice\"",[202,2723,2724],{"class":271},": {",[202,2726,1565],{"class":211},[202,2728,2729],{"class":271},": bcrypt.hash(",[202,2731,2732],{"class":211},"\"s3cret\"",[202,2734,2735],{"class":271},"), ",[202,2737,2738],{"class":211},"\"scopes\"",[202,2740,1823],{"class":271},[202,2742,2678],{"class":211},[202,2744,812],{"class":271},[202,2746,2688],{"class":211},[202,2748,2749],{"class":271},"]}}\n",[202,2751,2752],{"class":105,"line":955},[202,2753,754],{"emptyLinePlaceholder":753},[202,2755,2756],{"class":105,"line":974},[202,2757,754],{"emptyLinePlaceholder":753},[202,2759,2760,2763,2765,2767],{"class":105,"line":986},[202,2761,2762],{"class":207},"@router.post",[202,2764,961],{"class":271},[202,2766,2664],{"class":211},[202,2768,818],{"class":271},[202,2770,2771,2773,2775,2778,2781,2783,2786,2788],{"class":105,"line":995},[202,2772,1641],{"class":267},[202,2774,1644],{"class":267},[202,2776,2777],{"class":207}," login",[202,2779,2780],{"class":271},"(form: OAuth2PasswordRequestForm ",[202,2782,275],{"class":267},[202,2784,2785],{"class":271}," Depends()) -> ",[202,2787,1041],{"class":227},[202,2789,983],{"class":271},[202,2791,2792,2795,2797,2800],{"class":105,"line":1003},[202,2793,2794],{"class":271},"    user ",[202,2796,275],{"class":267},[202,2798,2799],{"class":227}," USERS",[202,2801,2802],{"class":271},".get(form.username)\n",[202,2804,2805,2807,2809,2812,2815,2817,2820,2822],{"class":105,"line":1011},[202,2806,1315],{"class":267},[202,2808,1552],{"class":267},[202,2810,2811],{"class":271}," user ",[202,2813,2814],{"class":267},"or",[202,2816,1552],{"class":267},[202,2818,2819],{"class":271}," bcrypt.verify(form.password, user[",[202,2821,1565],{"class":211},[202,2823,1568],{"class":271},[202,2825,2826,2828],{"class":105,"line":1016},[202,2827,1449],{"class":267},[202,2829,2360],{"class":271},[202,2831,2832,2834,2836],{"class":105,"line":1021},[202,2833,2365],{"class":271},[202,2835,1455],{"class":227},[202,2837,1199],{"class":271},[202,2839,2840,2843],{"class":105,"line":1027},[202,2841,2842],{"class":211},"            \"Incorrect username or password\"",[202,2844,1199],{"class":271},[202,2846,2847,2850,2852,2854,2856],{"class":105,"line":1052},[202,2848,2849],{"class":271},"            {",[202,2851,2052],{"class":211},[202,2853,1194],{"class":271},[202,2855,2057],{"class":211},[202,2857,2696],{"class":271},[202,2859,2860],{"class":105,"line":1057},[202,2861,2345],{"class":271},[202,2863,2864,2867,2869,2872,2875,2878,2881,2884,2886,2888,2890,2893,2895],{"class":105,"line":1062},[202,2865,2866],{"class":271},"    granted ",[202,2868,275],{"class":267},[202,2870,2871],{"class":271}," [s ",[202,2873,2874],{"class":267},"for",[202,2876,2877],{"class":271}," s ",[202,2879,2880],{"class":267},"in",[202,2882,2883],{"class":271}," form.scopes ",[202,2885,1360],{"class":267},[202,2887,2877],{"class":271},[202,2889,2880],{"class":267},[202,2891,2892],{"class":271}," user[",[202,2894,2738],{"class":211},[202,2896,2897],{"class":271},"]]\n",[202,2899,2900,2902],{"class":105,"line":1093},[202,2901,1211],{"class":267},[202,2903,1141],{"class":271},[202,2905,2906,2909],{"class":105,"line":1099},[202,2907,2908],{"class":211},"        \"access_token\"",[202,2910,2911],{"class":271},": create_access_token(form.username, granted),\n",[202,2913,2914,2917,2919,2922],{"class":105,"line":1114},[202,2915,2916],{"class":211},"        \"token_type\"",[202,2918,1194],{"class":271},[202,2920,2921],{"class":211},"\"bearer\"",[202,2923,1199],{"class":271},[202,2925,2926],{"class":105,"line":1130},[202,2927,1205],{"class":271},[202,2929,2930],{"class":105,"line":1144},[202,2931,754],{"emptyLinePlaceholder":753},[202,2933,2934],{"class":105,"line":1170},[202,2935,754],{"emptyLinePlaceholder":753},[202,2937,2938,2940,2942,2945],{"class":105,"line":1179},[202,2939,1641],{"class":267},[202,2941,1644],{"class":267},[202,2943,2944],{"class":207}," require_scopes",[202,2946,2947],{"class":271},"(\n",[202,2949,2950,2953,2955,2957],{"class":105,"line":1188},[202,2951,2952],{"class":271},"    security: SecurityScopes, token: ",[202,2954,1036],{"class":227},[202,2956,803],{"class":267},[202,2958,2959],{"class":271}," Depends(oauth2_scheme)\n",[202,2961,2962,2964,2966],{"class":105,"line":1202},[202,2963,1086],{"class":271},[202,2965,1041],{"class":227},[202,2967,983],{"class":271},[202,2969,2970,2972,2974],{"class":105,"line":1208},[202,2971,2109],{"class":271},[202,2973,275],{"class":267},[202,2975,2976],{"class":271}," decode_access_token(token)\n",[202,2978,2979,2982,2984,2987,2990,2992],{"class":105,"line":1247},[202,2980,2981],{"class":271},"    token_scopes ",[202,2983,275],{"class":267},[202,2985,2986],{"class":227}," set",[202,2988,2989],{"class":271},"(payload.get(",[202,2991,2738],{"class":211},[202,2993,2994],{"class":271},", []))\n",[202,2996,2997,3000,3003,3005],{"class":105,"line":1252},[202,2998,2999],{"class":267},"    for",[202,3001,3002],{"class":271}," scope ",[202,3004,2880],{"class":267},[202,3006,3007],{"class":271}," security.scopes:\n",[202,3009,3010,3013,3015,3018,3021],{"class":105,"line":1257},[202,3011,3012],{"class":267},"        if",[202,3014,3002],{"class":271},[202,3016,3017],{"class":267},"not",[202,3019,3020],{"class":267}," in",[202,3022,3023],{"class":271}," token_scopes:\n",[202,3025,3026,3029],{"class":105,"line":1289},[202,3027,3028],{"class":267},"            raise",[202,3030,2360],{"class":271},[202,3032,3033,3036,3039],{"class":105,"line":1312},[202,3034,3035],{"class":271},"                status.",[202,3037,3038],{"class":227},"HTTP_403_FORBIDDEN",[202,3040,1199],{"class":271},[202,3042,3043,3046,3049,3051,3054,3056,3058],{"class":105,"line":1327},[202,3044,3045],{"class":267},"                f",[202,3047,3048],{"class":211},"\"Missing scope: ",[202,3050,1226],{"class":227},[202,3052,3053],{"class":271},"scope",[202,3055,1232],{"class":227},[202,3057,1217],{"class":211},[202,3059,1199],{"class":271},[202,3061,3062,3065,3067,3069,3071,3074,3076,3078,3080,3083],{"class":105,"line":1336},[202,3063,3064],{"class":271},"                {",[202,3066,2052],{"class":211},[202,3068,1194],{"class":271},[202,3070,1300],{"class":267},[202,3072,3073],{"class":211},"'Bearer scope=\"",[202,3075,1226],{"class":227},[202,3077,3053],{"class":271},[202,3079,1232],{"class":227},[202,3081,3082],{"class":211},"\"'",[202,3084,2696],{"class":271},[202,3086,3087],{"class":105,"line":1352},[202,3088,3089],{"class":271},"            )\n",[202,3091,3092,3094],{"class":105,"line":1377},[202,3093,1211],{"class":267},[202,3095,3096],{"class":271}," payload\n",[14,3098,3099,3100,3103,3104,3107],{},"Guard a route by declaring the scopes it needs. Note the status codes: an unknown or expired token is ",[199,3101,3102],{},"401"," because the caller has not proved who they are, while a valid token missing a scope is ",[199,3105,3106],{},"403"," because they have — and are not allowed. Conflating the two is the single most common cause of clients retrying forever on an error a refresh will never fix.",[192,3109,3111],{"className":711,"code":3110,"language":713,"meta":197,"style":197},"from fastapi import Security\n\n@app.post(\"\u002Fv1\u002Fitems\")\nasync def create_item(user=Security(require_scopes, scopes=[\"write\"])) -> dict:\n    return {\"created_by\": user[\"sub\"]}\n",[199,3112,3113,3124,3128,3140,3173],{"__ignoreMap":197},[202,3114,3115,3117,3119,3121],{"class":105,"line":204},[202,3116,740],{"class":267},[202,3118,761],{"class":271},[202,3120,725],{"class":267},[202,3122,3123],{"class":271}," Security\n",[202,3125,3126],{"class":105,"line":231},[202,3127,754],{"emptyLinePlaceholder":753},[202,3129,3130,3133,3135,3138],{"class":105,"line":325},[202,3131,3132],{"class":207},"@app.post",[202,3134,961],{"class":271},[202,3136,3137],{"class":211},"\"\u002Fv1\u002Fitems\"",[202,3139,818],{"class":271},[202,3141,3142,3144,3146,3149,3152,3154,3157,3160,3162,3164,3166,3169,3171],{"class":105,"line":338},[202,3143,1641],{"class":267},[202,3145,1644],{"class":267},[202,3147,3148],{"class":207}," create_item",[202,3150,3151],{"class":271},"(user",[202,3153,275],{"class":267},[202,3155,3156],{"class":271},"Security(require_scopes, ",[202,3158,3159],{"class":882},"scopes",[202,3161,275],{"class":267},[202,3163,2272],{"class":271},[202,3165,2688],{"class":211},[202,3167,3168],{"class":271},"])) -> ",[202,3170,1041],{"class":227},[202,3172,983],{"class":271},[202,3174,3175,3177,3179,3182,3185,3187],{"class":105,"line":354},[202,3176,1211],{"class":267},[202,3178,1806],{"class":271},[202,3180,3181],{"class":211},"\"created_by\"",[202,3183,3184],{"class":271},": user[",[202,3186,2337],{"class":211},[202,3188,1839],{"class":271},[32,3190,41,3195,41,3198,41,3201,41,3208,41,3211,41,3213,41,3218,41,3220,41,3223,41,3225,41,3229,41,3233,41,3235,41,3237,41,3241,41,3246,41,3251,41,3255,41,3258,41,3262,41,3266,41,3268,41,3272,41,3274,41,3278],{"viewBox":3191,"role":35,"ariaLabelledBy":3192,"xmlns":39,"style":40},"0 0 720 330",[3193,3194],"apiauth-seq-t","apiauth-seq-d",[43,3196,3197],{"id":3193},"Token issue and scope check sequence",[47,3199,3200],{"id":3194},"A sequence diagram: the client posts credentials to the token endpoint, the app checks the password hash and reads granted scopes from the store, returns a JWT, and the client then calls a protected route that answers 200 or 403 depending on scope.",[51,3202,53,3203,41],{},[55,3204,63,3206,53],{"id":3205,"viewBox":58,"refX":59,"refY":60,"markerWidth":61,"markerHeight":61,"orient":62},"apiauth-seq-arrow",[65,3207],{"d":67,"fill":68},[70,3209],{"x":72,"y":72,"width":73,"height":3210,"fill":75},"330",[70,3212],{"x":80,"y":79,"width":132,"height":472,"rx":697,"fill":89,"stroke":90,"style":91},[77,3214,3217],{"x":3215,"y":3216,"fill":96,"style":117},"110","45","Client",[70,3219],{"x":468,"y":79,"width":132,"height":472,"rx":697,"fill":89,"stroke":113,"style":91},[77,3221,3222],{"x":700,"y":3216,"fill":96,"style":117},"FastAPI app",[70,3224],{"x":2497,"y":79,"width":132,"height":472,"rx":697,"fill":89,"stroke":171,"style":91},[77,3226,3228],{"x":3227,"y":3216,"fill":96,"style":117},"610","User + key store",[105,3230],{"x1":3215,"y1":639,"x2":3215,"y2":3231,"stroke":477,"style":3232},"305","stroke-width:1.5;stroke-dasharray:5 5;",[105,3234],{"x1":700,"y1":639,"x2":700,"y2":3231,"stroke":477,"style":3232},[105,3236],{"x1":3227,"y1":639,"x2":3227,"y2":3231,"stroke":477,"style":3232},[77,3238,3240],{"x":475,"y":3239,"fill":96,"style":122},"92","POST \u002Fauth\u002Ftoken",[105,3242],{"x1":3215,"y1":3243,"x2":3244,"y2":3243,"stroke":68,"style":3245},"102","356","stroke-width:2;marker-end:url(#apiauth-seq-arrow);",[77,3247,3250],{"x":3248,"y":3249,"fill":96,"style":122},"485","132","check password hash",[105,3252],{"x1":700,"y1":3253,"x2":3254,"y2":3253,"stroke":68,"style":3245},"142","606",[77,3256,3257],{"x":3248,"y":679,"fill":68,"style":122},"granted scopes",[105,3259],{"x1":3227,"y1":556,"x2":3260,"y2":556,"stroke":68,"style":3261},"364","stroke-width:2;stroke-dasharray:6 4;marker-end:url(#apiauth-seq-arrow);",[77,3263,3265],{"x":475,"y":3264,"fill":68,"style":122},"212","JWT, exp 900 s",[105,3267],{"x1":700,"y1":152,"x2":667,"y2":152,"stroke":68,"style":3261},[77,3269,3271],{"x":475,"y":3270,"fill":96,"style":122},"252","GET \u002Fv1\u002Fitems + Bearer",[105,3273],{"x1":3215,"y1":505,"x2":3244,"y2":505,"stroke":68,"style":3245},[77,3275,3277],{"x":475,"y":3276,"fill":68,"style":122},"292","200 ok, or 403 no scope",[105,3279],{"x1":700,"y1":3280,"x2":667,"y2":3280,"stroke":68,"style":3261},"302",[14,3282,3283],{},"Most real APIs end up accepting both credential types on the same routes: a key for the customer's backend, a bearer token for their dashboard session. Resolve them in one dependency and normalise the result, so every downstream concern — quota, logging, billing — sees a single principal shape regardless of how the caller authenticated.",[192,3285,3287],{"className":711,"code":3286,"language":713,"meta":197,"style":197},"# dual.py\nimport os\nfrom typing import Annotated\n\nfrom fastapi import Depends, Header, HTTPException, status\n\nfrom apikeys import Principal, verify_api_key\nfrom jwttoken import decode_access_token\n\nDEFAULT_PLAN = os.getenv(\"JWT_DEFAULT_PLAN\", \"pro\")\n\n\nasync def current_principal(\n    authorization: Annotated[str | None, Header()] = None,\n    x_api_key: Annotated[str | None, Header()] = None,\n) -> Principal:\n    match (authorization or \"\").split(\" \", 1):\n        case [\"Bearer\", token] if token.strip():\n            claims = decode_access_token(token.strip())\n            return Principal(\n                key_id=claims.get(\"jti\", \"\"),\n                account_id=claims[\"sub\"],\n                plan=DEFAULT_PLAN,\n            )\n        case _ if x_api_key:\n            return verify_api_key(x_api_key)\n        case _:\n            raise HTTPException(\n                status.HTTP_401_UNAUTHORIZED,\n                \"Provide X-API-Key or a Bearer token\",\n                {\"WWW-Authenticate\": \"Bearer\"},\n            )\n",[199,3288,3289,3294,3300,3312,3316,3327,3331,3342,3353,3357,3376,3380,3384,3395,3415,3434,3438,3464,3482,3492,3500,3519,3533,3544,3548,3560,3567,3574,3580,3588,3595,3607],{"__ignoreMap":197},[202,3290,3291],{"class":105,"line":204},[202,3292,3293],{"class":296},"# dual.py\n",[202,3295,3296,3298],{"class":105,"line":231},[202,3297,725],{"class":267},[202,3299,728],{"class":271},[202,3301,3302,3304,3307,3309],{"class":105,"line":325},[202,3303,740],{"class":267},[202,3305,3306],{"class":271}," typing ",[202,3308,725],{"class":267},[202,3310,3311],{"class":271}," Annotated\n",[202,3313,3314],{"class":105,"line":338},[202,3315,754],{"emptyLinePlaceholder":753},[202,3317,3318,3320,3322,3324],{"class":105,"line":354},[202,3319,740],{"class":267},[202,3321,761],{"class":271},[202,3323,725],{"class":267},[202,3325,3326],{"class":271}," Depends, Header, HTTPException, status\n",[202,3328,3329],{"class":105,"line":367},[202,3330,754],{"emptyLinePlaceholder":753},[202,3332,3333,3335,3337,3339],{"class":105,"line":380},[202,3334,740],{"class":267},[202,3336,1739],{"class":271},[202,3338,725],{"class":267},[202,3340,3341],{"class":271}," Principal, verify_api_key\n",[202,3343,3344,3346,3348,3350],{"class":105,"line":393},[202,3345,740],{"class":267},[202,3347,2625],{"class":271},[202,3349,725],{"class":267},[202,3351,3352],{"class":271}," decode_access_token\n",[202,3354,3355],{"class":105,"line":793},[202,3356,754],{"emptyLinePlaceholder":753},[202,3358,3359,3362,3364,3366,3369,3371,3374],{"class":105,"line":798},[202,3360,3361],{"class":227},"DEFAULT_PLAN",[202,3363,803],{"class":267},[202,3365,806],{"class":271},[202,3367,3368],{"class":211},"\"JWT_DEFAULT_PLAN\"",[202,3370,812],{"class":271},[202,3372,3373],{"class":211},"\"pro\"",[202,3375,818],{"class":271},[202,3377,3378],{"class":105,"line":821},[202,3379,754],{"emptyLinePlaceholder":753},[202,3381,3382],{"class":105,"line":841},[202,3383,754],{"emptyLinePlaceholder":753},[202,3385,3386,3388,3390,3393],{"class":105,"line":866},[202,3387,1641],{"class":267},[202,3389,1644],{"class":267},[202,3391,3392],{"class":207}," current_principal",[202,3394,2947],{"class":271},[202,3396,3397,3400,3402,3404,3406,3409,3411,3413],{"class":105,"line":871},[202,3398,3399],{"class":271},"    authorization: Annotated[",[202,3401,1036],{"class":227},[202,3403,1654],{"class":267},[202,3405,1284],{"class":227},[202,3407,3408],{"class":271},", Header()] ",[202,3410,275],{"class":267},[202,3412,1284],{"class":227},[202,3414,1199],{"class":271},[202,3416,3417,3420,3422,3424,3426,3428,3430,3432],{"class":105,"line":903},[202,3418,3419],{"class":271},"    x_api_key: Annotated[",[202,3421,1036],{"class":227},[202,3423,1654],{"class":267},[202,3425,1284],{"class":227},[202,3427,3408],{"class":271},[202,3429,275],{"class":267},[202,3431,1284],{"class":227},[202,3433,1199],{"class":271},[202,3435,3436],{"class":105,"line":908},[202,3437,1399],{"class":271},[202,3439,3440,3443,3446,3448,3451,3454,3457,3459,3461],{"class":105,"line":914},[202,3441,3442],{"class":267},"    match",[202,3444,3445],{"class":271}," (authorization ",[202,3447,2814],{"class":267},[202,3449,3450],{"class":211}," \"\"",[202,3452,3453],{"class":271},").split(",[202,3455,3456],{"class":211},"\" \"",[202,3458,812],{"class":271},[202,3460,1826],{"class":227},[202,3462,3463],{"class":271},"):\n",[202,3465,3466,3469,3472,3474,3477,3479],{"class":105,"line":945},[202,3467,3468],{"class":267},"        case",[202,3470,3471],{"class":271}," [",[202,3473,2057],{"class":211},[202,3475,3476],{"class":271},", token] ",[202,3478,1360],{"class":267},[202,3480,3481],{"class":271}," token.strip():\n",[202,3483,3484,3487,3489],{"class":105,"line":950},[202,3485,3486],{"class":271},"            claims ",[202,3488,275],{"class":267},[202,3490,3491],{"class":271}," decode_access_token(token.strip())\n",[202,3493,3494,3497],{"class":105,"line":955},[202,3495,3496],{"class":267},"            return",[202,3498,3499],{"class":271}," Principal(\n",[202,3501,3502,3505,3507,3510,3513,3515,3517],{"class":105,"line":974},[202,3503,3504],{"class":882},"                key_id",[202,3506,275],{"class":267},[202,3508,3509],{"class":271},"claims.get(",[202,3511,3512],{"class":211},"\"jti\"",[202,3514,812],{"class":271},[202,3516,815],{"class":211},[202,3518,1167],{"class":271},[202,3520,3521,3524,3526,3529,3531],{"class":105,"line":986},[202,3522,3523],{"class":882},"                account_id",[202,3525,275],{"class":267},[202,3527,3528],{"class":271},"claims[",[202,3530,2337],{"class":211},[202,3532,2277],{"class":271},[202,3534,3535,3538,3540,3542],{"class":105,"line":995},[202,3536,3537],{"class":882},"                plan",[202,3539,275],{"class":267},[202,3541,3361],{"class":227},[202,3543,1199],{"class":271},[202,3545,3546],{"class":105,"line":1003},[202,3547,3089],{"class":271},[202,3549,3550,3552,3555,3557],{"class":105,"line":1011},[202,3551,3468],{"class":267},[202,3553,3554],{"class":271}," _ ",[202,3556,1360],{"class":267},[202,3558,3559],{"class":271}," x_api_key:\n",[202,3561,3562,3564],{"class":105,"line":1016},[202,3563,3496],{"class":267},[202,3565,3566],{"class":271}," verify_api_key(x_api_key)\n",[202,3568,3569,3571],{"class":105,"line":1021},[202,3570,3468],{"class":267},[202,3572,3573],{"class":271}," _:\n",[202,3575,3576,3578],{"class":105,"line":1027},[202,3577,3028],{"class":267},[202,3579,2360],{"class":271},[202,3581,3582,3584,3586],{"class":105,"line":1052},[202,3583,3035],{"class":271},[202,3585,1455],{"class":227},[202,3587,1199],{"class":271},[202,3589,3590,3593],{"class":105,"line":1057},[202,3591,3592],{"class":211},"                \"Provide X-API-Key or a Bearer token\"",[202,3594,1199],{"class":271},[202,3596,3597,3599,3601,3603,3605],{"class":105,"line":1062},[202,3598,3064],{"class":271},[202,3600,2052],{"class":211},[202,3602,1194],{"class":271},[202,3604,2057],{"class":211},[202,3606,2696],{"class":271},[202,3608,3609],{"class":105,"line":1093},[202,3610,3089],{"class":271},[181,3612],{},[184,3614,3616],{"id":3615},"step-4-keep-bcrypt-off-the-hot-path","Step 4: Keep bcrypt off the hot path",[14,3618,3619,3620,3624],{},"Here is where auth quietly becomes an infrastructure bill. Bcrypt is slow ",[3621,3622,3623],"em",{},"on purpose"," — that is its whole value against offline cracking — but running it on every single request means you are paying for a deliberately expensive computation millions of times a month to answer a question whose answer almost never changes.",[14,3626,3627],{},"The fix is a short-lived verification cache. Hash the presented key with HMAC-SHA256 (fast, keyed, not reversible) to derive a cache key, look up the resolved principal in Redis, and only fall back to bcrypt on a miss. A five-minute TTL means a revoked key stops working within five minutes at worst — set it shorter if your security posture demands it, and delete the cache entry explicitly at revocation time so the common case is instant.",[192,3629,3631],{"className":711,"code":3630,"language":713,"meta":197,"style":197},"# authcache.py\nimport hashlib\nimport hmac\nimport json\nimport os\n\nimport redis.asyncio as redis\nfrom fastapi import Depends, HTTPException, status\nfrom fastapi.security import APIKeyHeader\n\nfrom apikeys import Principal, verify_api_key\n\nREDIS_URL = os.getenv(\"REDIS_URL\", \"redis:\u002F\u002Flocalhost:6379\u002F0\")\nAUTH_CACHE_TTL = int(os.getenv(\"AUTH_CACHE_TTL_SECONDS\", \"300\"))\n_PEPPER = os.getenv(\"API_KEY_PEPPER\", \"\").encode()\n\n_redis = redis.from_url(REDIS_URL, decode_responses=True)\napi_key_header = APIKeyHeader(name=\"X-API-Key\", auto_error=False)\n\n\ndef cache_slot(raw_key: str) -> str:\n    digest = hmac.new(_PEPPER, raw_key.encode(), hashlib.sha256).hexdigest()\n    return f\"auth:principal:{digest}\"\n\n\nasync def cached_principal(raw: str | None = Depends(api_key_header)) -> Principal:\n    if not raw:\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Missing X-API-Key header\")\n    slot = cache_slot(raw)\n    if hit := await _redis.get(slot):\n        return Principal(**json.loads(hit))\n    principal = verify_api_key(raw)  # bcrypt runs here, on a miss only\n    await _redis.set(slot, json.dumps(principal.__dict__), ex=AUTH_CACHE_TTL)\n    return principal\n\n\nasync def invalidate_key(raw_key: str) -> None:\n    \"\"\"Call from your revoke endpoint so the cut-off is immediate.\"\"\"\n    await _redis.delete(cache_slot(raw_key))\n",[199,3632,3633,3638,3645,3652,3659,3665,3669,3682,3692,3702,3706,3716,3720,3739,3760,3778,3782,3805,3829,3833,3837,3855,3870,3888,3892,3896,3917,3925,3939,3949,3965,3977,3990,4012,4019,4023,4027,4046,4051],{"__ignoreMap":197},[202,3634,3635],{"class":105,"line":204},[202,3636,3637],{"class":296},"# authcache.py\n",[202,3639,3640,3642],{"class":105,"line":231},[202,3641,725],{"class":267},[202,3643,3644],{"class":271}," hashlib\n",[202,3646,3647,3649],{"class":105,"line":325},[202,3648,725],{"class":267},[202,3650,3651],{"class":271}," hmac\n",[202,3653,3654,3656],{"class":105,"line":338},[202,3655,725],{"class":267},[202,3657,3658],{"class":271}," json\n",[202,3660,3661,3663],{"class":105,"line":354},[202,3662,725],{"class":267},[202,3664,728],{"class":271},[202,3666,3667],{"class":105,"line":367},[202,3668,754],{"emptyLinePlaceholder":753},[202,3670,3671,3673,3676,3679],{"class":105,"line":380},[202,3672,725],{"class":267},[202,3674,3675],{"class":271}," redis.asyncio ",[202,3677,3678],{"class":267},"as",[202,3680,3681],{"class":271}," redis\n",[202,3683,3684,3686,3688,3690],{"class":105,"line":393},[202,3685,740],{"class":267},[202,3687,761],{"class":271},[202,3689,725],{"class":267},[202,3691,766],{"class":271},[202,3693,3694,3696,3698,3700],{"class":105,"line":793},[202,3695,740],{"class":267},[202,3697,773],{"class":271},[202,3699,725],{"class":267},[202,3701,778],{"class":271},[202,3703,3704],{"class":105,"line":798},[202,3705,754],{"emptyLinePlaceholder":753},[202,3707,3708,3710,3712,3714],{"class":105,"line":821},[202,3709,740],{"class":267},[202,3711,1739],{"class":271},[202,3713,725],{"class":267},[202,3715,3341],{"class":271},[202,3717,3718],{"class":105,"line":841},[202,3719,754],{"emptyLinePlaceholder":753},[202,3721,3722,3725,3727,3729,3732,3734,3737],{"class":105,"line":866},[202,3723,3724],{"class":227},"REDIS_URL",[202,3726,803],{"class":267},[202,3728,806],{"class":271},[202,3730,3731],{"class":211},"\"REDIS_URL\"",[202,3733,812],{"class":271},[202,3735,3736],{"class":211},"\"redis:\u002F\u002Flocalhost:6379\u002F0\"",[202,3738,818],{"class":271},[202,3740,3741,3744,3746,3748,3750,3753,3755,3758],{"class":105,"line":871},[202,3742,3743],{"class":227},"AUTH_CACHE_TTL",[202,3745,803],{"class":267},[202,3747,849],{"class":227},[202,3749,852],{"class":271},[202,3751,3752],{"class":211},"\"AUTH_CACHE_TTL_SECONDS\"",[202,3754,812],{"class":271},[202,3756,3757],{"class":211},"\"300\"",[202,3759,863],{"class":271},[202,3761,3762,3765,3767,3769,3771,3773,3775],{"class":105,"line":903},[202,3763,3764],{"class":227},"_PEPPER",[202,3766,803],{"class":267},[202,3768,806],{"class":271},[202,3770,809],{"class":211},[202,3772,812],{"class":271},[202,3774,815],{"class":211},[202,3776,3777],{"class":271},").encode()\n",[202,3779,3780],{"class":105,"line":908},[202,3781,754],{"emptyLinePlaceholder":753},[202,3783,3784,3787,3789,3792,3794,3796,3799,3801,3803],{"class":105,"line":914},[202,3785,3786],{"class":271},"_redis ",[202,3788,275],{"class":267},[202,3790,3791],{"class":271}," redis.from_url(",[202,3793,3724],{"class":227},[202,3795,812],{"class":271},[202,3797,3798],{"class":882},"decode_responses",[202,3800,275],{"class":267},[202,3802,969],{"class":227},[202,3804,818],{"class":271},[202,3806,3807,3809,3811,3813,3815,3817,3819,3821,3823,3825,3827],{"class":105,"line":945},[202,3808,874],{"class":271},[202,3810,275],{"class":267},[202,3812,879],{"class":271},[202,3814,883],{"class":882},[202,3816,275],{"class":267},[202,3818,888],{"class":211},[202,3820,812],{"class":271},[202,3822,893],{"class":882},[202,3824,275],{"class":267},[202,3826,898],{"class":227},[202,3828,818],{"class":271},[202,3830,3831],{"class":105,"line":950},[202,3832,754],{"emptyLinePlaceholder":753},[202,3834,3835],{"class":105,"line":955},[202,3836,754],{"emptyLinePlaceholder":753},[202,3838,3839,3841,3844,3847,3849,3851,3853],{"class":105,"line":974},[202,3840,1065],{"class":267},[202,3842,3843],{"class":207}," cache_slot",[202,3845,3846],{"class":271},"(raw_key: ",[202,3848,1036],{"class":227},[202,3850,1086],{"class":271},[202,3852,1036],{"class":227},[202,3854,983],{"class":271},[202,3856,3857,3860,3862,3865,3867],{"class":105,"line":986},[202,3858,3859],{"class":271},"    digest ",[202,3861,275],{"class":267},[202,3863,3864],{"class":271}," hmac.new(",[202,3866,3764],{"class":227},[202,3868,3869],{"class":271},", raw_key.encode(), hashlib.sha256).hexdigest()\n",[202,3871,3872,3874,3876,3879,3881,3884,3886],{"class":105,"line":995},[202,3873,1211],{"class":267},[202,3875,1214],{"class":267},[202,3877,3878],{"class":211},"\"auth:principal:",[202,3880,1226],{"class":227},[202,3882,3883],{"class":271},"digest",[202,3885,1232],{"class":227},[202,3887,1244],{"class":211},[202,3889,3890],{"class":105,"line":1003},[202,3891,754],{"emptyLinePlaceholder":753},[202,3893,3894],{"class":105,"line":1011},[202,3895,754],{"emptyLinePlaceholder":753},[202,3897,3898,3900,3902,3905,3907,3909,3911,3913,3915],{"class":105,"line":1016},[202,3899,1641],{"class":267},[202,3901,1644],{"class":267},[202,3903,3904],{"class":207}," cached_principal",[202,3906,1265],{"class":271},[202,3908,1036],{"class":227},[202,3910,1654],{"class":267},[202,3912,1284],{"class":227},[202,3914,803],{"class":267},[202,3916,1661],{"class":271},[202,3918,3919,3921,3923],{"class":105,"line":1021},[202,3920,1315],{"class":267},[202,3922,1552],{"class":267},[202,3924,1324],{"class":271},[202,3926,3927,3929,3931,3933,3935,3937],{"class":105,"line":1027},[202,3928,1449],{"class":267},[202,3930,1452],{"class":271},[202,3932,1455],{"class":227},[202,3934,812],{"class":271},[202,3936,1684],{"class":211},[202,3938,818],{"class":271},[202,3940,3941,3944,3946],{"class":105,"line":1052},[202,3942,3943],{"class":271},"    slot ",[202,3945,275],{"class":267},[202,3947,3948],{"class":271}," cache_slot(raw)\n",[202,3950,3951,3953,3956,3959,3962],{"class":105,"line":1057},[202,3952,1315],{"class":267},[202,3954,3955],{"class":271}," hit ",[202,3957,3958],{"class":267},":=",[202,3960,3961],{"class":267}," await",[202,3963,3964],{"class":271}," _redis.get(slot):\n",[202,3966,3967,3969,3971,3974],{"class":105,"line":1062},[202,3968,1330],{"class":267},[202,3970,1591],{"class":271},[202,3972,3973],{"class":267},"**",[202,3975,3976],{"class":271},"json.loads(hit))\n",[202,3978,3979,3982,3984,3987],{"class":105,"line":1093},[202,3980,3981],{"class":271},"    principal ",[202,3983,275],{"class":267},[202,3985,3986],{"class":271}," verify_api_key(raw)  ",[202,3988,3989],{"class":296},"# bcrypt runs here, on a miss only\n",[202,3991,3992,3995,3998,4001,4003,4006,4008,4010],{"class":105,"line":1099},[202,3993,3994],{"class":267},"    await",[202,3996,3997],{"class":271}," _redis.set(slot, json.dumps(principal.",[202,3999,4000],{"class":227},"__dict__",[202,4002,2735],{"class":271},[202,4004,4005],{"class":882},"ex",[202,4007,275],{"class":267},[202,4009,3743],{"class":227},[202,4011,818],{"class":271},[202,4013,4014,4016],{"class":105,"line":1114},[202,4015,1211],{"class":267},[202,4017,4018],{"class":271}," principal\n",[202,4020,4021],{"class":105,"line":1130},[202,4022,754],{"emptyLinePlaceholder":753},[202,4024,4025],{"class":105,"line":1144},[202,4026,754],{"emptyLinePlaceholder":753},[202,4028,4029,4031,4033,4036,4038,4040,4042,4044],{"class":105,"line":1170},[202,4030,1641],{"class":267},[202,4032,1644],{"class":267},[202,4034,4035],{"class":207}," invalidate_key",[202,4037,3846],{"class":271},[202,4039,1036],{"class":227},[202,4041,1086],{"class":271},[202,4043,560],{"class":227},[202,4045,983],{"class":271},[202,4047,4048],{"class":105,"line":1179},[202,4049,4050],{"class":211},"    \"\"\"Call from your revoke endpoint so the cut-off is immediate.\"\"\"\n",[202,4052,4053,4055],{"class":105,"line":1188},[202,4054,3994],{"class":267},[202,4056,4057],{"class":271}," _redis.delete(cache_slot(raw_key))\n",[14,4059,4060,4061,4065,4066,4070],{},"Never cache the raw key as the Redis value or the Redis key — the HMAC digest is what goes over the wire to Redis, so a compromised cache yields nothing usable. The general caching patterns, including connection pooling and stampede control, are covered in ",[18,4062,4064],{"href":4063},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","caching Python API responses with Redis",", and if you run a single instance and are wondering whether Redis is worth the hop at all, ",[18,4067,4069],{"href":4068},"\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"," answers exactly that.",[32,4072,41,4077,41,4080,41,4083,41,4086,41,4089,41,4094,41,4097,41,4100,41,4103,41,4106,41,4109,41,4114,41,4118,41,4122,41,4125,41,4129,41,4132,41,4135,41,4139,41,4142,41,4146,41,4150,41,4154,41,4157,41,4159,41,4161,41,4163],{"viewBox":4073,"role":35,"ariaLabelledBy":4074,"xmlns":39,"style":40},"0 0 720 270",[4075,4076],"apiauth-lat-t","apiauth-lat-d",[43,4078,4079],{"id":4075},"Credential verification cost by method",[47,4081,4082],{"id":4076},"A log-scale bar chart comparing median verification time: HMAC index lookup at 0.03 milliseconds, JWT HS256 verification at 0.05, a Redis cache hit at 0.9, and a bcrypt cost-12 verification at 62 milliseconds.",[70,4084],{"x":72,"y":72,"width":73,"height":4085,"fill":75},"270",[77,4087,4088],{"x":700,"y":80,"fill":68,"style":117},"Median credential check, log scale",[105,4090],{"x1":4091,"y1":4092,"x2":4091,"y2":4093,"stroke":477,"style":495},"285","50","215",[105,4095],{"x1":4096,"y1":4092,"x2":4096,"y2":4093,"stroke":477,"style":495},"390",[105,4098],{"x1":4099,"y1":4092,"x2":4099,"y2":4093,"stroke":477,"style":495},"495",[105,4101],{"x1":4102,"y1":4092,"x2":4102,"y2":4093,"stroke":477,"style":495},"600",[105,4104],{"x1":4105,"y1":4092,"x2":4105,"y2":4093,"stroke":68,"style":91},"180",[70,4107],{"x":4105,"y":4108,"width":4092,"height":517,"rx":1836,"fill":113},"62",[77,4110,4113],{"x":107,"y":4111,"fill":96,"style":4112},"79","text-anchor:end;font-size:12;font-family:var(--font-sans);","HMAC index lookup",[77,4115,4117],{"x":572,"y":4111,"fill":96,"style":4116},"text-anchor:start;font-size:12;font-family:var(--font-sans);","0.03 ms",[70,4119],{"x":4105,"y":4120,"width":4121,"height":517,"rx":1836,"fill":90},"100","73",[77,4123,4124],{"x":107,"y":530,"fill":96,"style":4112},"JWT HS256 verify",[77,4126,4128],{"x":4127,"y":530,"fill":96,"style":4116},"261","0.05 ms",[70,4130],{"x":4105,"y":668,"width":4131,"height":517,"rx":1836,"fill":137},"205",[77,4133,4134],{"x":107,"y":546,"fill":96,"style":4112},"Redis cache hit",[77,4136,4138],{"x":4137,"y":546,"fill":96,"style":4116},"393","0.9 ms",[70,4140],{"x":4105,"y":2502,"width":4141,"height":517,"rx":1836,"fill":171},"398",[77,4143,4145],{"x":107,"y":4144,"fill":96,"style":4112},"193","bcrypt (cost 12)",[77,4147,4149],{"x":4148,"y":4144,"fill":96,"style":4116},"586","62 ms",[77,4151,4153],{"x":4105,"y":4152,"fill":68,"style":102},"232","0.01",[77,4155,4156],{"x":4091,"y":4152,"fill":68,"style":102},"0.1",[77,4158,1826],{"x":4096,"y":4152,"fill":68,"style":102},[77,4160,88],{"x":4099,"y":4152,"fill":68,"style":102},[77,4162,4120],{"x":4102,"y":4152,"fill":68,"style":102},[77,4164,4166],{"x":700,"y":4165,"fill":68,"style":122},"254","milliseconds per verification (log scale)",[14,4168,4169],{},"Read the chart as ratios, not absolutes: a bcrypt verification costs roughly seventy times a Redis round trip and more than a thousand times a JWT signature check. That is the difference between auth being a rounding error on your infrastructure bill and auth being the reason you upgrade your instance.",[181,4171],{},[184,4173,4175],{"id":4174},"step-5-credentials-you-consume","Step 5: Credentials you consume",[14,4177,4178,4179,4182],{},"You also ",[3621,4180,4181],{},"use"," APIs — Stripe, an LLM provider, a shipping carrier — and those credentials need the same discipline in the other direction. Load them from the environment, verify them at startup so a bad key fails loudly during deploy instead of at 3am, and support overlapping keys so rotating one never causes downtime.",[192,4184,4186],{"className":711,"code":4185,"language":713,"meta":197,"style":197},"# thirdparty.py\nimport os\n\nimport httpx\n\nUPSTREAM_URL = os.getenv(\"UPSTREAM_API_URL\", \"https:\u002F\u002Fapi.example.com\")\nUPSTREAM_TIMEOUT = float(os.getenv(\"UPSTREAM_TIMEOUT_SECONDS\", \"10\"))\n# Comma-separated allows old+new keys live at once during rotation.\nUPSTREAM_KEYS = [k for k in os.getenv(\"UPSTREAM_API_KEYS\", \"\").split(\",\") if k]\n\n\nasync def verify_upstream_credentials() -> str:\n    \"\"\"Call from the lifespan handler; returns the first key that works.\"\"\"\n    if not UPSTREAM_KEYS:\n        raise RuntimeError(\"UPSTREAM_API_KEYS is not set\")\n    async with httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT) as client:\n        for key in UPSTREAM_KEYS:\n            resp = await client.get(\n                f\"{UPSTREAM_URL}\u002Fv1\u002Fping\",\n                headers={\"Authorization\": f\"Bearer {key}\"},\n            )\n            match resp.status_code:\n                case 200:\n                    return key\n                case 401 | 403:\n                    continue\n                case _:\n                    resp.raise_for_status()\n    raise RuntimeError(\"No usable upstream API key\")\n",[199,4187,4188,4193,4199,4203,4210,4214,4233,4255,4260,4299,4303,4307,4323,4328,4339,4353,4378,4392,4404,4418,4448,4452,4460,4470,4478,4492,4497,4503,4508],{"__ignoreMap":197},[202,4189,4190],{"class":105,"line":204},[202,4191,4192],{"class":296},"# thirdparty.py\n",[202,4194,4195,4197],{"class":105,"line":231},[202,4196,725],{"class":267},[202,4198,728],{"class":271},[202,4200,4201],{"class":105,"line":325},[202,4202,754],{"emptyLinePlaceholder":753},[202,4204,4205,4207],{"class":105,"line":338},[202,4206,725],{"class":267},[202,4208,4209],{"class":271}," httpx\n",[202,4211,4212],{"class":105,"line":354},[202,4213,754],{"emptyLinePlaceholder":753},[202,4215,4216,4219,4221,4223,4226,4228,4231],{"class":105,"line":367},[202,4217,4218],{"class":227},"UPSTREAM_URL",[202,4220,803],{"class":267},[202,4222,806],{"class":271},[202,4224,4225],{"class":211},"\"UPSTREAM_API_URL\"",[202,4227,812],{"class":271},[202,4229,4230],{"class":211},"\"https:\u002F\u002Fapi.example.com\"",[202,4232,818],{"class":271},[202,4234,4235,4238,4240,4243,4245,4248,4250,4253],{"class":105,"line":380},[202,4236,4237],{"class":227},"UPSTREAM_TIMEOUT",[202,4239,803],{"class":267},[202,4241,4242],{"class":227}," float",[202,4244,852],{"class":271},[202,4246,4247],{"class":211},"\"UPSTREAM_TIMEOUT_SECONDS\"",[202,4249,812],{"class":271},[202,4251,4252],{"class":211},"\"10\"",[202,4254,863],{"class":271},[202,4256,4257],{"class":105,"line":393},[202,4258,4259],{"class":296},"# Comma-separated allows old+new keys live at once during rotation.\n",[202,4261,4262,4265,4267,4270,4272,4275,4277,4279,4282,4284,4286,4288,4291,4294,4296],{"class":105,"line":793},[202,4263,4264],{"class":227},"UPSTREAM_KEYS",[202,4266,803],{"class":267},[202,4268,4269],{"class":271}," [k ",[202,4271,2874],{"class":267},[202,4273,4274],{"class":271}," k ",[202,4276,2880],{"class":267},[202,4278,806],{"class":271},[202,4280,4281],{"class":211},"\"UPSTREAM_API_KEYS\"",[202,4283,812],{"class":271},[202,4285,815],{"class":211},[202,4287,3453],{"class":271},[202,4289,4290],{"class":211},"\",\"",[202,4292,4293],{"class":271},") ",[202,4295,1360],{"class":267},[202,4297,4298],{"class":271}," k]\n",[202,4300,4301],{"class":105,"line":798},[202,4302,754],{"emptyLinePlaceholder":753},[202,4304,4305],{"class":105,"line":821},[202,4306,754],{"emptyLinePlaceholder":753},[202,4308,4309,4311,4313,4316,4319,4321],{"class":105,"line":841},[202,4310,1641],{"class":267},[202,4312,1644],{"class":267},[202,4314,4315],{"class":207}," verify_upstream_credentials",[202,4317,4318],{"class":271},"() -> ",[202,4320,1036],{"class":227},[202,4322,983],{"class":271},[202,4324,4325],{"class":105,"line":866},[202,4326,4327],{"class":211},"    \"\"\"Call from the lifespan handler; returns the first key that works.\"\"\"\n",[202,4329,4330,4332,4334,4337],{"class":105,"line":871},[202,4331,1315],{"class":267},[202,4333,1552],{"class":267},[202,4335,4336],{"class":227}," UPSTREAM_KEYS",[202,4338,983],{"class":271},[202,4340,4341,4343,4346,4348,4351],{"class":105,"line":903},[202,4342,1449],{"class":267},[202,4344,4345],{"class":227}," RuntimeError",[202,4347,961],{"class":271},[202,4349,4350],{"class":211},"\"UPSTREAM_API_KEYS is not set\"",[202,4352,818],{"class":271},[202,4354,4355,4358,4361,4364,4367,4369,4371,4373,4375],{"class":105,"line":908},[202,4356,4357],{"class":267},"    async",[202,4359,4360],{"class":267}," with",[202,4362,4363],{"class":271}," httpx.AsyncClient(",[202,4365,4366],{"class":882},"timeout",[202,4368,275],{"class":267},[202,4370,4237],{"class":227},[202,4372,4293],{"class":271},[202,4374,3678],{"class":267},[202,4376,4377],{"class":271}," client:\n",[202,4379,4380,4383,4386,4388,4390],{"class":105,"line":914},[202,4381,4382],{"class":267},"        for",[202,4384,4385],{"class":271}," key ",[202,4387,2880],{"class":267},[202,4389,4336],{"class":227},[202,4391,983],{"class":271},[202,4393,4394,4397,4399,4401],{"class":105,"line":945},[202,4395,4396],{"class":271},"            resp ",[202,4398,275],{"class":267},[202,4400,3961],{"class":267},[202,4402,4403],{"class":271}," client.get(\n",[202,4405,4406,4408,4410,4413,4416],{"class":105,"line":950},[202,4407,3045],{"class":267},[202,4409,1217],{"class":211},[202,4411,4412],{"class":227},"{UPSTREAM_URL}",[202,4414,4415],{"class":211},"\u002Fv1\u002Fping\"",[202,4417,1199],{"class":271},[202,4419,4420,4423,4425,4427,4430,4432,4434,4437,4439,4442,4444,4446],{"class":105,"line":955},[202,4421,4422],{"class":882},"                headers",[202,4424,275],{"class":267},[202,4426,1226],{"class":271},[202,4428,4429],{"class":211},"\"Authorization\"",[202,4431,1194],{"class":271},[202,4433,1300],{"class":267},[202,4435,4436],{"class":211},"\"Bearer ",[202,4438,1226],{"class":227},[202,4440,4441],{"class":271},"key",[202,4443,1232],{"class":227},[202,4445,1217],{"class":211},[202,4447,2696],{"class":271},[202,4449,4450],{"class":105,"line":974},[202,4451,3089],{"class":271},[202,4453,4454,4457],{"class":105,"line":986},[202,4455,4456],{"class":267},"            match",[202,4458,4459],{"class":271}," resp.status_code:\n",[202,4461,4462,4465,4468],{"class":105,"line":995},[202,4463,4464],{"class":267},"                case",[202,4466,4467],{"class":227}," 200",[202,4469,983],{"class":271},[202,4471,4472,4475],{"class":105,"line":1003},[202,4473,4474],{"class":267},"                    return",[202,4476,4477],{"class":271}," key\n",[202,4479,4480,4482,4485,4487,4490],{"class":105,"line":1011},[202,4481,4464],{"class":267},[202,4483,4484],{"class":227}," 401",[202,4486,1654],{"class":267},[202,4488,4489],{"class":227}," 403",[202,4491,983],{"class":271},[202,4493,4494],{"class":105,"line":1016},[202,4495,4496],{"class":267},"                    continue\n",[202,4498,4499,4501],{"class":105,"line":1021},[202,4500,4464],{"class":267},[202,4502,3573],{"class":271},[202,4504,4505],{"class":105,"line":1027},[202,4506,4507],{"class":271},"                    resp.raise_for_status()\n",[202,4509,4510,4513,4515,4517,4520],{"class":105,"line":1052},[202,4511,4512],{"class":267},"    raise",[202,4514,4345],{"class":227},[202,4516,961],{"class":271},[202,4518,4519],{"class":211},"\"No usable upstream API key\"",[202,4521,818],{"class":271},[14,4523,4524,4525,4529],{},"Wire that into your FastAPI lifespan and a deploy with a dead key fails the health check instead of silently degrading every customer request into a 502. Rotation then becomes three boring deploys: add the new key alongside the old, swap the primary at the provider, drop the old key. That is the same overlap principle you offer your own customers, and it is why ",[18,4526,4528],{"href":4527},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fzero-downtime-deploys-for-python-apis\u002F","zero-downtime deploys for Python APIs"," and credential rotation belong in the same runbook.",[14,4531,4532,4533,4537],{},"Two related cases deserve their own handling. Tokens you hold on a customer's behalf must be encrypted at rest and refreshed on a schedule, not lazily on first failure. And inbound credentials that are not really credentials — webhook signatures — need constant-time comparison and a replay window rather than a lookup; see ",[18,4534,4536],{"href":4535},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F","verifying Stripe webhook signatures"," for that pattern done properly.",[181,4539],{},[184,4541,4543],{"id":4542},"configuration-reference","Configuration reference",[4545,4546,4547,4563],"table",{},[4548,4549,4550],"thead",{},[4551,4552,4553,4557,4560],"tr",{},[4554,4555,4556],"th",{},"Env var",[4554,4558,4559],{},"Default",[4554,4561,4562],{},"Production recommendation",[4564,4565,4566,4581,4599,4613,4626,4639,4653,4667],"tbody",{},[4551,4567,4568,4573,4578],{},[4569,4570,4571],"td",{},[199,4572,1927],{},[4569,4574,4575],{},[3621,4576,4577],{},"(none)",[4569,4579,4580],{},"256-bit random hex from a secret manager, unique per environment",[4551,4582,4583,4587,4591],{},[4569,4584,4585],{},[199,4586,1945],{},[4569,4588,4589],{},[199,4590,2515],{},[4569,4592,4593,4595,4596,4598],{},[199,4594,2515],{}," for one service; ",[199,4597,2519],{}," when several verify and one signs",[4551,4600,4601,4605,4610],{},[4569,4602,4603],{},[199,4604,1964],{},[4569,4606,4607],{},[199,4608,4609],{},"900",[4569,4611,4612],{},"5–15 min, paired with a longer refresh token",[4551,4614,4615,4619,4623],{},[4569,4616,4617],{},[199,4618,2020],{},[4569,4620,4621],{},[199,4622,80],{},[4569,4624,4625],{},"30 s absorbs clock drift without extending token life",[4551,4627,4628,4632,4636],{},[4569,4629,4630],{},[199,4631,409],{},[4569,4633,4634],{},[3621,4635,4577],{},[4569,4637,4638],{},"Random pepper in the app env only, never in the database",[4551,4640,4641,4645,4650],{},[4569,4642,4643],{},[199,4644,844],{},[4569,4646,4647],{},[199,4648,4649],{},"12",[4569,4651,4652],{},"12 in production, 4 in tests to keep the suite fast",[4551,4654,4655,4660,4664],{},[4569,4656,4657],{},[199,4658,4659],{},"AUTH_CACHE_TTL_SECONDS",[4569,4661,4662],{},[199,4663,74],{},[4569,4665,4666],{},"60–300 s; delete the slot on revoke for instant cut-off",[4551,4668,4669,4674,4678],{},[4569,4670,4671],{},[199,4672,4673],{},"UPSTREAM_API_KEYS",[4569,4675,4676],{},[3621,4677,4577],{},[4569,4679,4680],{},"Comma-separated for overlap rotation; never logged",[14,4682,4683,4684,4687],{},"Set ",[199,4685,4686],{},"BCRYPT_ROUNDS=4"," in your test environment specifically. A suite with two hundred auth tests at cost factor 12 spends over twenty seconds purely hashing, and a slow suite is a suite people stop running.",[181,4689],{},[184,4691,4693],{"id":4692},"gotchas-and-failure-modes","Gotchas and failure modes",[4695,4696,4697,4704,4710,4723,4741,4747,4765,4782],"ol",{},[4698,4699,4700,4703],"li",{},[612,4701,4702],{},"Storing raw keys."," A leaked database of plaintext keys is an instant breach and a disclosure email to every customer. Hash with bcrypt plus a pepper; show the raw key exactly once, at issue time.",[4698,4705,4706,4709],{},[612,4707,4708],{},"Scanning the key table."," Verifying by looping bcrypt over every stored hash turns a 60 ms operation into 60 ms times your customer count. Embed an indexed key id in the key so exactly one hash is checked.",[4698,4711,4712,4715,4716,4719,4720,4722],{},[612,4713,4714],{},"Tokens with no expiry."," A JWT without ",[199,4717,4718],{},"exp"," is valid forever and cannot be recalled. Always set ",[199,4721,4718],{},", keep access tokens short, and use refresh tokens for longevity.",[4698,4724,4725,4728,4729,4732,4733,4736,4737,4740],{},[612,4726,4727],{},"Unpinned algorithms."," Calling ",[199,4730,4731],{},"jwt.decode"," without ",[199,4734,4735],{},"algorithms=[...]"," lets an attacker submit an unsigned ",[199,4738,4739],{},"alg: none"," token, or downgrade an RS256 deployment by signing with the public key as an HMAC secret. Always pass the allowed list.",[4698,4742,4743,4746],{},[612,4744,4745],{},"Returning 403 where you mean 401."," Clients treat 401 as \"refresh and retry\" and 403 as \"give up\". Swap them and well-behaved clients either hammer you forever or drop a recoverable session.",[4698,4748,4749,4752,4753,414,4756,4759,4760,4764],{},[612,4750,4751],{},"Leaking keys in logs and URLs."," Never put secrets in query strings — they land in access logs, browser history and proxy caches. Scrub ",[199,4754,4755],{},"Authorization",[199,4757,4758],{},"X-API-Key"," in your logging middleware; ",[18,4761,4763],{"href":4762},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging with structlog"," makes that a single processor rather than a grep-and-pray.",[4698,4766,4767,4770,4771,4773,4774,4777,4778,4781],{},[612,4768,4769],{},"Non-constant-time comparison."," Comparing secrets with ",[199,4772,1321],{}," leaks length and content through timing. Use ",[199,4775,4776],{},"bcrypt.verify"," for hashes and ",[199,4779,4780],{},"hmac.compare_digest"," for any raw-string compare, and burn equivalent time on the not-found path.",[4698,4783,4784,4787],{},[612,4785,4786],{},"One key per customer."," If a customer cannot hold two valid keys at once, every rotation is an outage, and they will simply never rotate. Support multiple active keys per account from the start.",[181,4789],{},[184,4791,4793],{"id":4792},"verification","Verification",[14,4795,4796],{},"Get a token, then call a protected route. The happy path:",[192,4798,4800],{"className":194,"code":4799,"language":196,"meta":197,"style":197},"TOKEN=$(curl -s -X POST \"$API_BASE_URL\u002Fauth\u002Ftoken\" \\\n  -d \"username=alice&password=s3cret&scope=read write\" \\\n  | python -c \"import sys,json;print(json.load(sys.stdin)['access_token'])\")\n\ncurl -s -X POST -H \"Authorization: Bearer $TOKEN\" \"$API_BASE_URL\u002Fv1\u002Fitems\"\n# -> {\"created_by\":\"alice\"}\n",[199,4801,4802,4835,4845,4861,4865,4893],{"__ignoreMap":197},[202,4803,4804,4807,4809,4812,4815,4818,4821,4824,4827,4830,4833],{"class":105,"line":204},[202,4805,4806],{"class":271},"TOKEN",[202,4808,275],{"class":267},[202,4810,4811],{"class":271},"$(",[202,4813,4814],{"class":207},"curl",[202,4816,4817],{"class":227}," -s",[202,4819,4820],{"class":227}," -X",[202,4822,4823],{"class":211}," POST",[202,4825,4826],{"class":211}," \"",[202,4828,4829],{"class":271},"$API_BASE_URL",[202,4831,4832],{"class":211},"\u002Fauth\u002Ftoken\"",[202,4834,228],{"class":227},[202,4836,4837,4840,4843],{"class":105,"line":231},[202,4838,4839],{"class":227},"  -d",[202,4841,4842],{"class":211}," \"username=alice&password=s3cret&scope=read write\"",[202,4844,228],{"class":227},[202,4846,4847,4850,4853,4856,4859],{"class":105,"line":325},[202,4848,4849],{"class":267},"  |",[202,4851,4852],{"class":207}," python",[202,4854,4855],{"class":227}," -c",[202,4857,4858],{"class":211}," \"import sys,json;print(json.load(sys.stdin)['access_token'])\"",[202,4860,818],{"class":271},[202,4862,4863],{"class":105,"line":338},[202,4864,754],{"emptyLinePlaceholder":753},[202,4866,4867,4869,4871,4873,4875,4878,4881,4884,4886,4888,4890],{"class":105,"line":354},[202,4868,4814],{"class":207},[202,4870,4817],{"class":227},[202,4872,4820],{"class":227},[202,4874,4823],{"class":211},[202,4876,4877],{"class":227}," -H",[202,4879,4880],{"class":211}," \"Authorization: Bearer ",[202,4882,4883],{"class":271},"$TOKEN",[202,4885,1217],{"class":211},[202,4887,4826],{"class":211},[202,4889,4829],{"class":271},[202,4891,4892],{"class":211},"\u002Fv1\u002Fitems\"\n",[202,4894,4895],{"class":105,"line":367},[202,4896,4897],{"class":296},"# -> {\"created_by\":\"alice\"}\n",[14,4899,4900,4901,4903,4904,4907,4908,4910],{},"Now confirm the rejection paths. An invalid token must return ",[199,4902,3102],{}," with a ",[199,4905,4906],{},"WWW-Authenticate"," header; a valid token lacking a scope must return ",[199,4909,3106],{},":",[192,4912,4914],{"className":194,"code":4913,"language":196,"meta":197,"style":197},"curl -i -X POST -H \"Authorization: Bearer not-a-real-token\" \"$API_BASE_URL\u002Fv1\u002Fitems\"\n# HTTP\u002F1.1 401 Unauthorized\n# www-authenticate: Bearer\n# {\"detail\":\"Invalid token\"}\n",[199,4915,4916,4938,4943,4948],{"__ignoreMap":197},[202,4917,4918,4920,4923,4925,4927,4929,4932,4934,4936],{"class":105,"line":204},[202,4919,4814],{"class":207},[202,4921,4922],{"class":227}," -i",[202,4924,4820],{"class":227},[202,4926,4823],{"class":211},[202,4928,4877],{"class":227},[202,4930,4931],{"class":211}," \"Authorization: Bearer not-a-real-token\"",[202,4933,4826],{"class":211},[202,4935,4829],{"class":271},[202,4937,4892],{"class":211},[202,4939,4940],{"class":105,"line":231},[202,4941,4942],{"class":296},"# HTTP\u002F1.1 401 Unauthorized\n",[202,4944,4945],{"class":105,"line":325},[202,4946,4947],{"class":296},"# www-authenticate: Bearer\n",[202,4949,4950],{"class":105,"line":338},[202,4951,4952],{"class":296},"# {\"detail\":\"Invalid token\"}\n",[14,4954,4955],{},"Manual curl proves it once; a test proves it on every deploy. Pin both branches with an async test that drives the app in-process:",[192,4957,4959],{"className":711,"code":4958,"language":713,"meta":197,"style":197},"# test_auth.py\nimport os\n\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom main import app\n\nBASE = os.getenv(\"TEST_BASE_URL\", \"http:\u002F\u002Ftest\")\n\n\n@pytest.fixture\ndef anyio_backend() -> str:\n    return \"asyncio\"\n\n\n@pytest.mark.anyio\nasync def test_missing_key_is_401() -> None:\n    async with AsyncClient(transport=ASGITransport(app=app), base_url=BASE) as client:\n        resp = await client.get(\"\u002Fv1\u002Fdata\")\n    assert resp.status_code == 401\n    assert \"X-API-Key\" in resp.json()[\"detail\"]\n",[199,4960,4961,4966,4972,4976,4983,4995,4999,5011,5015,5034,5038,5042,5047,5060,5067,5071,5075,5080,5095,5133,5149,5162],{"__ignoreMap":197},[202,4962,4963],{"class":105,"line":204},[202,4964,4965],{"class":296},"# test_auth.py\n",[202,4967,4968,4970],{"class":105,"line":231},[202,4969,725],{"class":267},[202,4971,728],{"class":271},[202,4973,4974],{"class":105,"line":325},[202,4975,754],{"emptyLinePlaceholder":753},[202,4977,4978,4980],{"class":105,"line":338},[202,4979,725],{"class":267},[202,4981,4982],{"class":271}," pytest\n",[202,4984,4985,4987,4990,4992],{"class":105,"line":354},[202,4986,740],{"class":267},[202,4988,4989],{"class":271}," httpx ",[202,4991,725],{"class":267},[202,4993,4994],{"class":271}," ASGITransport, AsyncClient\n",[202,4996,4997],{"class":105,"line":367},[202,4998,754],{"emptyLinePlaceholder":753},[202,5000,5001,5003,5006,5008],{"class":105,"line":380},[202,5002,740],{"class":267},[202,5004,5005],{"class":271}," main ",[202,5007,725],{"class":267},[202,5009,5010],{"class":271}," app\n",[202,5012,5013],{"class":105,"line":393},[202,5014,754],{"emptyLinePlaceholder":753},[202,5016,5017,5020,5022,5024,5027,5029,5032],{"class":105,"line":793},[202,5018,5019],{"class":227},"BASE",[202,5021,803],{"class":267},[202,5023,806],{"class":271},[202,5025,5026],{"class":211},"\"TEST_BASE_URL\"",[202,5028,812],{"class":271},[202,5030,5031],{"class":211},"\"http:\u002F\u002Ftest\"",[202,5033,818],{"class":271},[202,5035,5036],{"class":105,"line":798},[202,5037,754],{"emptyLinePlaceholder":753},[202,5039,5040],{"class":105,"line":821},[202,5041,754],{"emptyLinePlaceholder":753},[202,5043,5044],{"class":105,"line":841},[202,5045,5046],{"class":207},"@pytest.fixture\n",[202,5048,5049,5051,5054,5056,5058],{"class":105,"line":866},[202,5050,1065],{"class":267},[202,5052,5053],{"class":207}," anyio_backend",[202,5055,4318],{"class":271},[202,5057,1036],{"class":227},[202,5059,983],{"class":271},[202,5061,5062,5064],{"class":105,"line":871},[202,5063,1211],{"class":267},[202,5065,5066],{"class":211}," \"asyncio\"\n",[202,5068,5069],{"class":105,"line":903},[202,5070,754],{"emptyLinePlaceholder":753},[202,5072,5073],{"class":105,"line":908},[202,5074,754],{"emptyLinePlaceholder":753},[202,5076,5077],{"class":105,"line":914},[202,5078,5079],{"class":207},"@pytest.mark.anyio\n",[202,5081,5082,5084,5086,5089,5091,5093],{"class":105,"line":945},[202,5083,1641],{"class":267},[202,5085,1644],{"class":267},[202,5087,5088],{"class":207}," test_missing_key_is_401",[202,5090,4318],{"class":271},[202,5092,560],{"class":227},[202,5094,983],{"class":271},[202,5096,5097,5099,5101,5104,5107,5109,5112,5115,5117,5120,5123,5125,5127,5129,5131],{"class":105,"line":950},[202,5098,4357],{"class":267},[202,5100,4360],{"class":267},[202,5102,5103],{"class":271}," AsyncClient(",[202,5105,5106],{"class":882},"transport",[202,5108,275],{"class":267},[202,5110,5111],{"class":271},"ASGITransport(",[202,5113,5114],{"class":882},"app",[202,5116,275],{"class":267},[202,5118,5119],{"class":271},"app), ",[202,5121,5122],{"class":882},"base_url",[202,5124,275],{"class":267},[202,5126,5019],{"class":227},[202,5128,4293],{"class":271},[202,5130,3678],{"class":267},[202,5132,4377],{"class":271},[202,5134,5135,5138,5140,5142,5145,5147],{"class":105,"line":955},[202,5136,5137],{"class":271},"        resp ",[202,5139,275],{"class":267},[202,5141,3961],{"class":267},[202,5143,5144],{"class":271}," client.get(",[202,5146,1776],{"class":211},[202,5148,818],{"class":271},[202,5150,5151,5154,5157,5159],{"class":105,"line":974},[202,5152,5153],{"class":267},"    assert",[202,5155,5156],{"class":271}," resp.status_code ",[202,5158,1321],{"class":267},[202,5160,5161],{"class":227}," 401\n",[202,5163,5164,5166,5169,5171,5174,5177],{"class":105,"line":986},[202,5165,5153],{"class":267},[202,5167,5168],{"class":211}," \"X-API-Key\"",[202,5170,3020],{"class":267},[202,5172,5173],{"class":271}," resp.json()[",[202,5175,5176],{"class":211},"\"detail\"",[202,5178,5179],{"class":271},"]\n",[14,5181,5182,5183,5186,5187,5190,5191,5195,5196,5200],{},"That pattern — ",[199,5184,5185],{},"ASGITransport"," plus ",[199,5188,5189],{},"AsyncClient",", no live server — is the fastest way to test auth, and it is expanded in ",[18,5192,5194],{"href":5193},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002F","testing async FastAPI endpoints with httpx",". If you get an unexpected 401 in production, walk through ",[18,5197,5199],{"href":5198},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fdebugging-401-unauthorized-api-errors\u002F","debugging 401 Unauthorized API errors"," — the usual culprits are a proxy stripping the header, clock skew on expiry, or a mismatched secret between environments.",[181,5202],{},[184,5204,5206],{"id":5205},"cost-and-performance-at-scale","Cost and performance at scale",[14,5208,5209],{},"JWT verification is pure CPU: a signature check plus a JSON decode, roughly 50 microseconds with no network call, so it adds nothing measurable to your bill. Hashed API-key verification is a different animal. At cost factor 12, one bcrypt check costs about 62 ms of CPU on a typical shared vCPU, and CPU is the thing you rent.",[14,5211,5212,5213,5216],{},"Do the arithmetic at burst, not at monthly totals — that is where the trap hides. One million requests a month averages 0.4 requests per second, which sounds harmless. But a customer's nightly batch job that fires 100 requests per second needs 6.2 CPU-seconds of hashing for every wall-clock second, meaning seven vCPUs dedicated to ",[3621,5214,5215],{},"checking passwords",". On typical managed-platform pricing, that is the difference between a roughly $25\u002Fmonth two-vCPU instance and a $175\u002Fmonth eight-vCPU one: about $150 a month of pure margin, burned. Add a Redis instance for ten dollars, cache the verified principal for five minutes, and the same burst is handled by the original box with capacity to spare.",[14,5218,5219,5220,5224,5225,5229],{},"Expressed per request, uncached bcrypt auth costs you around $0.00015 at that traffic shape. If you sell at a tenth of a cent per call, authentication alone eats 15% of your gross margin before your actual product does any work. Cached, it rounds to zero. This is the kind of line item worth modelling explicitly with the method in ",[18,5221,5223],{"href":5222},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","calculating cost per API request",", and it is also why the worker configuration matters: a blocking bcrypt call inside an async route pins an event loop, so size workers against vCPUs the way ",[18,5226,5228],{"href":5227},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002Fuvicorn-vs-gunicorn-worker-configuration\u002F","Uvicorn vs Gunicorn worker configuration"," recommends, and push hashing into a thread pool if you cannot cache it.",[181,5231],{},[184,5233,5235],{"id":5234},"faq","FAQ",[14,5237,5238,5241],{},[612,5239,5240],{},"What does authentication actually cost me per million requests?","\nRoughly nothing if you verify JWTs or cache verified keys — under fifty microseconds of CPU per call, invisible on any hosting bill. Uncached bcrypt on every request is the expensive path: at 62 ms per check, a burst of 100 requests per second needs about seven vCPUs, which on managed platforms is around $150 a month more than the two-vCPU box you would otherwise run. A ten-dollar Redis cache removes that entirely.",[14,5243,5244,5247,5248,5250,5251,453],{},[612,5245,5246],{},"How do I rotate a customer's key without breaking their integration?","\nNever overwrite a key in place. Issue a second key, let both work during an overlap window of a week or more, watch ",[199,5249,1845],{}," on the old row drop to zero, then revoke it. Customers rotate on their own schedule and nothing fails at 2am. The full schema and endpoint design is in ",[18,5252,1854],{"href":1853},[14,5254,5255,5258],{},[612,5256,5257],{},"Should I build this or pay for Auth0, Clerk or WorkOS?","\nBuild it if your callers are machines. Issuing hashed API keys is about two hundred lines you fully control, and hosted identity products charge per monthly active user — a model that maps badly onto server-to-server traffic. Buy it the moment you need social login, SAML for enterprise deals, or a compliance auditor asking who owns your password reset flow; that is months of work you would rather spend on the product.",[14,5260,5261,5264,5265,453],{},[612,5262,5263],{},"How do I stop one leaked key from draining my free tier?","\nRate limit and quota on the key id you resolve during authentication, not on IP — the principal object from this guide is exactly that hook. Cap free plans hard, alert on a step change in per-key volume, and expose a self-serve revoke button so a customer can kill a leaked key at 2am without you. The specific defences are in ",[18,5266,5268],{"href":5267},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fpreventing-free-tier-abuse\u002F","preventing free tier abuse",[14,5270,5271,5274,5275,5278,5279,453],{},[612,5272,5273],{},"What is the migration risk if I start with API keys and add JWTs later?","\nLow, as long as your routes depend on a normalised principal object rather than reading headers directly. Adding a second credential type then means one new branch in one dependency, as in the ",[199,5276,5277],{},"current_principal"," example above — no route changes, no client-visible break. The expensive migration is the reverse: retiring a credential type your customers already embedded in their code, which follows the same slow choreography as ",[18,5280,5282],{"href":5281},"\u002Fscaling-and-operating-production-python-apis\u002Fversioning-and-evolving-public-apis\u002Fdeprecating-an-api-endpoint-without-breaking-customers\u002F","deprecating an API endpoint without breaking customers",[181,5284],{},[184,5286,5288],{"id":5287},"related","Related",[14,5290,5291],{},[612,5292,5293],{},"Same topic area:",[5295,5296,5297,5302,5308],"ul",{},[4698,5298,5299,5301],{},[18,5300,29],{"href":28}," — the decision behind the code on this page, with the revocation trade-off spelled out.",[4698,5303,5304,5307],{},[18,5305,5306],{"href":1853},"Rotating API keys without downtime"," — the dual-key overlap pattern, customer-facing rotation endpoint and instant revocation.",[4698,5309,5310,5313],{},[18,5311,5312],{"href":451},"Implementing the OAuth2 authorization code flow in FastAPI"," — for when you act on a customer's account on another platform.",[14,5315,5316],{},[612,5317,5318],{},"Adjacent areas:",[5295,5320,5321,5327],{},[4698,5322,5323,5326],{},[18,5324,5325],{"href":434},"Best practices for API rate limiting"," — throttle on the principal this guide resolves.",[4698,5328,5329,5332],{},[18,5330,5331],{"href":599},"Tracking API usage and analytics"," — turn the verified key id into billable usage events.",[5334,5335,5336],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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);}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":197,"searchDepth":231,"depth":231,"links":5338},[5339,5340,5341,5342,5343,5344,5345,5346,5347,5348,5349,5350,5351],{"id":186,"depth":231,"text":187},{"id":441,"depth":231,"text":442},{"id":606,"depth":231,"text":607},{"id":1859,"depth":231,"text":1860},{"id":2528,"depth":231,"text":2529},{"id":3615,"depth":231,"text":3616},{"id":4174,"depth":231,"text":4175},{"id":4542,"depth":231,"text":4543},{"id":4692,"depth":231,"text":4693},{"id":4792,"depth":231,"text":4793},{"id":5205,"depth":231,"text":5206},{"id":5234,"depth":231,"text":5235},{"id":5287,"depth":231,"text":5288},"Ship secure FastAPI auth: prefixed hashed API keys, JWTs with scopes and expiry, cached verification, zero-downtime key rotation and third-party credentials.","md",{"pageTitle":5355,"type":5356,"datePublished":5357,"dateModified":5358},"Handling API Authentication in Python: Keys, JWT, OAuth2","guide","2026-06-18","2026-07-23","\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python",{"title":5,"description":5352},"getting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Findex","V_s9r86-yLhUoB6bdy2ojAmGwAwuqi1aWRYnJWN1SzE",{"@context":5364,"@type":5365,"mainEntity":5366},"https:\u002F\u002Fschema.org","FAQPage",[5367,5372,5375,5378,5381],{"@type":5368,"name":5240,"acceptedAnswer":5369},"Question",{"@type":5370,"text":5371},"Answer","Roughly nothing if you verify JWTs or cache verified keys — under fifty microseconds of CPU per call, invisible on any hosting bill. Uncached bcrypt on every request is the expensive path: at 62 ms per check, a burst of 100 requests per second needs about seven vCPUs, which on managed platforms is around $150 a month more than the two-vCPU box you would otherwise run. A ten-dollar Redis cache removes that entirely.",{"@type":5368,"name":5246,"acceptedAnswer":5373},{"@type":5370,"text":5374},"Never overwrite a key in place. Issue a second key, let both work during an overlap window of a week or more, watch last_used_at on the old row drop to zero, then revoke it. Customers rotate on their own schedule and nothing fails at 2am. The full schema and endpoint design is in rotating API keys without downtime.",{"@type":5368,"name":5257,"acceptedAnswer":5376},{"@type":5370,"text":5377},"Build it if your callers are machines. Issuing hashed API keys is about two hundred lines you fully control, and hosted identity products charge per monthly active user — a model that maps badly onto server-to-server traffic. Buy it the moment you need social login, SAML for enterprise deals, or a compliance auditor asking who owns your password reset flow; that is months of work you would rather spend on the product.",{"@type":5368,"name":5263,"acceptedAnswer":5379},{"@type":5370,"text":5380},"Rate limit and quota on the key id you resolve during authentication, not on IP — the principal object from this guide is exactly that hook. Cap free plans hard, alert on a step change in per-key volume, and expose a self-serve revoke button so a customer can kill a leaked key at 2am without you. The specific defences are in preventing free tier abuse.",{"@type":5368,"name":5273,"acceptedAnswer":5382},{"@type":5370,"text":5383},"Low, as long as your routes depend on a normalised principal object rather than reading headers directly. Adding a second credential type then means one new branch in one dependency, as in the current_principal example above — no route changes, no client-visible break. The expensive migration is the reverse: retiring a credential type your customers already embedded in their code, which follows the same slow choreography as deprecating an API endpoint without breaking customers. ---",1784887027768]