[{"data":1,"prerenderedAt":2641},["ShallowReactive",2],{"page-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F":3,"faq-schema-\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F":2620},{"id":4,"title":5,"body":6,"description":2610,"extension":2611,"meta":2612,"navigation":244,"path":2616,"seo":2617,"stem":2618,"__hash__":2619},"content\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002Findex.md","Rotating API Keys Without Downtime",{"type":7,"value":8,"toc":2598},"minimark",[9,13,23,26,29,34,47,50,53,168,170,174,177,180,187,640,647,745,748,974,976,980,995,1014,1093,1099,1558,1560,1564,1571,1574,2001,2013,2118,2120,2124,2127,2135,2362,2370,2372,2376,2379,2445,2448,2450,2454,2465,2478,2480,2484,2487,2489,2493,2500,2506,2522,2531,2542,2544,2548,2553,2575,2580,2594],[10,11,5],"h1",{"id":12},"rotating-api-keys-without-downtime",[14,15,16,17,22],"p",{},"Sooner or later you have to change a customer's API key. A contractor leaves, a key lands in a public repository, or your own security policy says credentials expire after a year. The naive move — generate a new key, overwrite the old row, email the customer — takes every integration they built down until a human notices and redeploys. This page resolves one decision: whether to swap keys atomically or to run two valid keys side by side, and it gives you the schema, the endpoint and the revocation path to do the second one properly. It is part of the ",[18,19,21],"a",{"href":20},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","Handling API Authentication in Python"," guide, which covers issuing and verifying credentials in the first place.",[14,24,25],{},"The short version: never swap. Overlap. A key is not a value you replace, it is a row with a lifecycle, and rotation is a state transition that two keys live through together.",[27,28],"hr",{},[30,31,33],"h2",{"id":32},"why-the-atomic-swap-always-costs-you-an-outage","Why the atomic swap always costs you an outage",[14,35,36,37,41,42,46],{},"Think about where your key actually lives on the customer's side. It is in a Render environment variable, a GitHub Actions secret, a Lambda config, a ",[38,39,40],"code",{},".env"," on someone's laptop, and probably pasted into a Postman collection. There is no single place to update, and no way for you to update any of them. The moment you invalidate the old value, every one of those call sites starts returning 401 — and your support inbox fills with the exact confusion described in ",[18,43,45],{"href":44},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fdebugging-401-unauthorized-api-errors\u002F","debugging 401 Unauthorized API errors",".",[14,48,49],{},"The cost is not just goodwill. If you bill per successful request, a two-hour outage on a customer doing 40 requests per second is roughly 288,000 unbilled calls, and worse, it teaches their engineering team that your API is fragile. Churn after a self-inflicted auth outage is brutal because the customer has a concrete, shareable story about why you broke them.",[14,51,52],{},"The overlap approach removes the cliff entirely. You mint the new key while the old one still works, hand the new value to the customer, and let them migrate call site by call site on their own schedule. The old key retires only when it stops receiving traffic, or when its grace deadline passes — whichever comes first.",[54,55,63,64,63,68,63,72,63,79,63,87,63,93,63,102,63,108,63,112,63,116,63,124,63,128,63,132,63,141,63,146,63,151,63,154,63,159,63,164],"svg",{"viewBox":56,"role":57,"ariaLabelledBy":58,"xmlns":61,"style":62},"0 0 720 300","img",[59,60],"rot-key-tl-t","rot-key-tl-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[65,66,67],"title",{"id":59},"Flag-day key swap versus dual-key overlap on a timeline",[69,70,71],"desc",{"id":60},"Two timelines. The top one shows an old key ending exactly where a new key begins, with a marked failure point at the boundary. The bottom one shows the old key and new key valid at the same time across a shaded grace window, so no request fails.",[73,74],"rect",{"x":75,"y":75,"width":76,"height":77,"fill":78},"0","720","300","var(--c-surface)",[80,81,86],"text",{"x":82,"y":83,"fill":84,"style":85},"20","28","var(--c-text)","font-size:14;font-family:var(--font-sans);","Two rotation strategies, same calendar",[80,88,92],{"x":82,"y":89,"fill":90,"style":91},"70","var(--c-text-muted)","font-size:12;font-family:var(--font-sans);","Flag-day swap",[73,94],{"x":82,"y":95,"width":96,"height":97,"rx":98,"fill":99,"stroke":100,"style":101},"85","330","34","6","var(--c-surface-alt)","var(--c-blue)","stroke-width:2;",[80,103,107],{"x":104,"y":105,"fill":84,"style":106},"185","107","text-anchor:middle;font-size:12;font-family:var(--font-sans);","Old key valid",[73,109],{"x":110,"y":95,"width":96,"height":97,"rx":98,"fill":99,"stroke":111,"style":101},"360","var(--c-teal)",[80,113,115],{"x":114,"y":105,"fill":84,"style":106},"525","New key valid",[117,118],"line",{"x1":119,"y1":120,"x2":119,"y2":121,"stroke":122,"style":123},"355","78","126","var(--c-coral)","stroke-width:3;",[80,125,127],{"x":119,"y":126,"fill":84,"style":106},"144","Every deployed client 401s here",[80,129,131],{"x":82,"y":130,"fill":90,"style":91},"178","Dual-key overlap",[73,133],{"x":96,"y":134,"width":135,"height":136,"rx":137,"fill":138,"opacity":139,"stroke":138,"style":140},"188","120","72","4","var(--c-yellow)","0.18","stroke-width:1.5;",[73,142],{"x":82,"y":143,"width":144,"height":145,"rx":98,"fill":99,"stroke":100,"style":101},"195","430","26",[80,147,150],{"x":148,"y":149,"fill":84,"style":106},"235","213","Old key — still valid",[73,152],{"x":96,"y":153,"width":110,"height":145,"rx":98,"fill":99,"stroke":111,"style":101},"227",[80,155,158],{"x":156,"y":157,"fill":84,"style":106},"510","245","New key — live",[80,160,163],{"x":161,"y":162,"fill":84,"style":106},"390","282","Grace window: both accepted",[80,165,167],{"x":166,"y":162,"fill":90,"style":106},"600","Zero failed calls",[27,169],{},[30,171,173],{"id":172},"store-keys-so-rotation-is-even-possible","Store keys so rotation is even possible",[14,175,176],{},"You cannot run an overlap window if your database holds one key column on the customer row. Rotation needs keys to be their own table with their own status, and it needs the stored form to be a hash — because the second reason you rotate is that a raw key leaked, and if you store raw keys a database dump leaks all of them at once.",[14,178,179],{},"Hashing creates a lookup problem: you cannot index a value you only see hashed at request time. Solve it by splitting the key into a public lookup id and a secret half. The lookup id is indexed and meaningless on its own; the secret half is what you hash and compare. This gives you an O(1) index hit followed by exactly one constant-time comparison, instead of hashing every key in the table.",[14,181,182,183,46],{},"Use HMAC-SHA-256 with a server-side pepper, not bcrypt. Bcrypt exists to slow down attacks on low-entropy human passwords; a 256-bit random key has no dictionary to attack, and bcrypt's deliberate 50–100 ms cost lands on every single authenticated request. At 200 requests per second that is enough CPU to force a second instance you did not need, which shows up directly in your ",[18,184,186],{"href":185},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","cost per API request",[188,189,194],"pre",{"className":190,"code":191,"language":192,"meta":193,"style":193},"language-python shiki shiki-themes github-light github-dark","# apikeys\u002Fmint.py\nimport hashlib\nimport hmac\nimport os\nimport secrets\n\nKEY_PREFIX = os.getenv(\"API_KEY_PREFIX\", \"pk_live\")\nKEY_PEPPER = os.getenv(\"API_KEY_PEPPER\", \"\")  # 32+ random bytes, from your secret store\n\n\ndef hash_secret(secret_part: str) -> str:\n    if not KEY_PEPPER:\n        raise RuntimeError(\"API_KEY_PEPPER is not set\")\n    return hmac.new(\n        KEY_PEPPER.encode(), secret_part.encode(), hashlib.sha256\n    ).hexdigest()\n\n\ndef mint_key() -> tuple[str, str, str]:\n    \"\"\"Return (raw_key, lookup_id, key_hash). Persist only the last two.\"\"\"\n    lookup_id = secrets.token_hex(6)          # 12 public chars, indexed\n    secret_part = secrets.token_urlsafe(32)   # 256 bits of entropy\n    raw_key = f\"{KEY_PREFIX}_{lookup_id}.{secret_part}\"\n    return raw_key, lookup_id, hash_secret(secret_part)\n\n\ndef split_key(raw_key: str) -> tuple[str, str] | None:\n    match raw_key.split(\".\", 1):\n        case [head, secret_part] if head.startswith(f\"{KEY_PREFIX}_\"):\n            return head.removeprefix(f\"{KEY_PREFIX}_\"), secret_part\n        case _:\n            return None\n","python","",[38,195,196,204,215,223,231,239,246,272,296,301,306,330,344,361,370,379,385,390,395,419,425,445,465,506,514,519,524,557,577,604,624,632],{"__ignoreMap":193},[197,198,200],"span",{"class":117,"line":199},1,[197,201,203],{"class":202},"sJ8bj","# apikeys\u002Fmint.py\n",[197,205,207,211],{"class":117,"line":206},2,[197,208,210],{"class":209},"szBVR","import",[197,212,214],{"class":213},"sVt8B"," hashlib\n",[197,216,218,220],{"class":117,"line":217},3,[197,219,210],{"class":209},[197,221,222],{"class":213}," hmac\n",[197,224,226,228],{"class":117,"line":225},4,[197,227,210],{"class":209},[197,229,230],{"class":213}," os\n",[197,232,234,236],{"class":117,"line":233},5,[197,235,210],{"class":209},[197,237,238],{"class":213}," secrets\n",[197,240,242],{"class":117,"line":241},6,[197,243,245],{"emptyLinePlaceholder":244},true,"\n",[197,247,249,253,256,259,263,266,269],{"class":117,"line":248},7,[197,250,252],{"class":251},"sj4cs","KEY_PREFIX",[197,254,255],{"class":209}," =",[197,257,258],{"class":213}," os.getenv(",[197,260,262],{"class":261},"sZZnC","\"API_KEY_PREFIX\"",[197,264,265],{"class":213},", ",[197,267,268],{"class":261},"\"pk_live\"",[197,270,271],{"class":213},")\n",[197,273,275,278,280,282,285,287,290,293],{"class":117,"line":274},8,[197,276,277],{"class":251},"KEY_PEPPER",[197,279,255],{"class":209},[197,281,258],{"class":213},[197,283,284],{"class":261},"\"API_KEY_PEPPER\"",[197,286,265],{"class":213},[197,288,289],{"class":261},"\"\"",[197,291,292],{"class":213},")  ",[197,294,295],{"class":202},"# 32+ random bytes, from your secret store\n",[197,297,299],{"class":117,"line":298},9,[197,300,245],{"emptyLinePlaceholder":244},[197,302,304],{"class":117,"line":303},10,[197,305,245],{"emptyLinePlaceholder":244},[197,307,309,312,316,319,322,325,327],{"class":117,"line":308},11,[197,310,311],{"class":209},"def",[197,313,315],{"class":314},"sScJk"," hash_secret",[197,317,318],{"class":213},"(secret_part: ",[197,320,321],{"class":251},"str",[197,323,324],{"class":213},") -> ",[197,326,321],{"class":251},[197,328,329],{"class":213},":\n",[197,331,333,336,339,342],{"class":117,"line":332},12,[197,334,335],{"class":209},"    if",[197,337,338],{"class":209}," not",[197,340,341],{"class":251}," KEY_PEPPER",[197,343,329],{"class":213},[197,345,347,350,353,356,359],{"class":117,"line":346},13,[197,348,349],{"class":209},"        raise",[197,351,352],{"class":251}," RuntimeError",[197,354,355],{"class":213},"(",[197,357,358],{"class":261},"\"API_KEY_PEPPER is not set\"",[197,360,271],{"class":213},[197,362,364,367],{"class":117,"line":363},14,[197,365,366],{"class":209},"    return",[197,368,369],{"class":213}," hmac.new(\n",[197,371,373,376],{"class":117,"line":372},15,[197,374,375],{"class":251},"        KEY_PEPPER",[197,377,378],{"class":213},".encode(), secret_part.encode(), hashlib.sha256\n",[197,380,382],{"class":117,"line":381},16,[197,383,384],{"class":213},"    ).hexdigest()\n",[197,386,388],{"class":117,"line":387},17,[197,389,245],{"emptyLinePlaceholder":244},[197,391,393],{"class":117,"line":392},18,[197,394,245],{"emptyLinePlaceholder":244},[197,396,398,400,403,406,408,410,412,414,416],{"class":117,"line":397},19,[197,399,311],{"class":209},[197,401,402],{"class":314}," mint_key",[197,404,405],{"class":213},"() -> tuple[",[197,407,321],{"class":251},[197,409,265],{"class":213},[197,411,321],{"class":251},[197,413,265],{"class":213},[197,415,321],{"class":251},[197,417,418],{"class":213},"]:\n",[197,420,422],{"class":117,"line":421},20,[197,423,424],{"class":261},"    \"\"\"Return (raw_key, lookup_id, key_hash). Persist only the last two.\"\"\"\n",[197,426,428,431,434,437,439,442],{"class":117,"line":427},21,[197,429,430],{"class":213},"    lookup_id ",[197,432,433],{"class":209},"=",[197,435,436],{"class":213}," secrets.token_hex(",[197,438,98],{"class":251},[197,440,441],{"class":213},")          ",[197,443,444],{"class":202},"# 12 public chars, indexed\n",[197,446,448,451,453,456,459,462],{"class":117,"line":447},22,[197,449,450],{"class":213},"    secret_part ",[197,452,433],{"class":209},[197,454,455],{"class":213}," secrets.token_urlsafe(",[197,457,458],{"class":251},"32",[197,460,461],{"class":213},")   ",[197,463,464],{"class":202},"# 256 bits of entropy\n",[197,466,468,471,473,476,479,482,485,488,491,494,496,498,501,503],{"class":117,"line":467},23,[197,469,470],{"class":213},"    raw_key ",[197,472,433],{"class":209},[197,474,475],{"class":209}," f",[197,477,478],{"class":261},"\"",[197,480,481],{"class":251},"{KEY_PREFIX}",[197,483,484],{"class":261},"_",[197,486,487],{"class":251},"{",[197,489,490],{"class":213},"lookup_id",[197,492,493],{"class":251},"}",[197,495,46],{"class":261},[197,497,487],{"class":251},[197,499,500],{"class":213},"secret_part",[197,502,493],{"class":251},[197,504,505],{"class":261},"\"\n",[197,507,509,511],{"class":117,"line":508},24,[197,510,366],{"class":209},[197,512,513],{"class":213}," raw_key, lookup_id, hash_secret(secret_part)\n",[197,515,517],{"class":117,"line":516},25,[197,518,245],{"emptyLinePlaceholder":244},[197,520,522],{"class":117,"line":521},26,[197,523,245],{"emptyLinePlaceholder":244},[197,525,527,529,532,535,537,540,542,544,546,549,552,555],{"class":117,"line":526},27,[197,528,311],{"class":209},[197,530,531],{"class":314}," split_key",[197,533,534],{"class":213},"(raw_key: ",[197,536,321],{"class":251},[197,538,539],{"class":213},") -> tuple[",[197,541,321],{"class":251},[197,543,265],{"class":213},[197,545,321],{"class":251},[197,547,548],{"class":213},"] ",[197,550,551],{"class":209},"|",[197,553,554],{"class":251}," None",[197,556,329],{"class":213},[197,558,560,563,566,569,571,574],{"class":117,"line":559},28,[197,561,562],{"class":209},"    match",[197,564,565],{"class":213}," raw_key.split(",[197,567,568],{"class":261},"\".\"",[197,570,265],{"class":213},[197,572,573],{"class":251},"1",[197,575,576],{"class":213},"):\n",[197,578,580,583,586,589,592,595,597,599,602],{"class":117,"line":579},29,[197,581,582],{"class":209},"        case",[197,584,585],{"class":213}," [head, secret_part] ",[197,587,588],{"class":209},"if",[197,590,591],{"class":213}," head.startswith(",[197,593,594],{"class":209},"f",[197,596,478],{"class":261},[197,598,481],{"class":251},[197,600,601],{"class":261},"_\"",[197,603,576],{"class":213},[197,605,607,610,613,615,617,619,621],{"class":117,"line":606},30,[197,608,609],{"class":209},"            return",[197,611,612],{"class":213}," head.removeprefix(",[197,614,594],{"class":209},[197,616,478],{"class":261},[197,618,481],{"class":251},[197,620,601],{"class":261},[197,622,623],{"class":213},"), secret_part\n",[197,625,627,629],{"class":117,"line":626},31,[197,628,582],{"class":209},[197,630,631],{"class":213}," _:\n",[197,633,635,637],{"class":117,"line":634},32,[197,636,609],{"class":209},[197,638,639],{"class":251}," None\n",[14,641,642,643,646],{},"The prefix is not decoration. ",[38,644,645],{},"pk_live_"," in front of the lookup id lets GitHub's secret scanning and your own log scrubbers recognise your credentials on sight, and it tells a customer at a glance whether they pasted a test key into production.",[54,648,63,652,63,655,63,658,63,660,63,663,63,668,63,673,63,677,63,680,63,684,63,687,63,690,63,694,63,697,63,700,63,704,63,707,63,711,63,714,63,717,63,721,63,724,63,726,63,728,63,732,63,736,63,738,63,741],{"viewBox":56,"role":57,"ariaLabelledBy":649,"xmlns":61,"style":62},[650,651],"rot-key-lk-t","rot-key-lk-d",[65,653,654],{"id":650},"Hashed API key lookup pipeline",[69,656,657],{"id":651},"A four-stage pipeline: the client header is split at the dot into a public lookup id and a secret, the lookup id hits an index, the secret is HMAC hashed and compared in constant time, and the result branches into a matched customer row or a 401 response.",[73,659],{"x":75,"y":75,"width":76,"height":77,"fill":78},[80,661,662],{"x":82,"y":83,"fill":84,"style":85},"One index hit, one constant-time compare",[73,664],{"x":82,"y":89,"width":665,"height":89,"rx":666,"fill":99,"stroke":667,"style":140},"150","8","var(--c-border)",[80,669,672],{"x":670,"y":671,"fill":84,"style":106},"95","98","Client header",[80,674,676],{"x":670,"y":675,"fill":90,"style":106},"118","pk_a1b2.secret",[73,678],{"x":679,"y":89,"width":665,"height":89,"rx":666,"fill":99,"stroke":100,"style":101},"200",[80,681,683],{"x":682,"y":671,"fill":84,"style":106},"275","Split at the dot",[80,685,686],{"x":682,"y":675,"fill":90,"style":106},"id + secret",[73,688],{"x":689,"y":89,"width":665,"height":89,"rx":666,"fill":99,"stroke":100,"style":101},"380",[80,691,693],{"x":692,"y":671,"fill":84,"style":106},"455","Index lookup",[80,695,696],{"x":692,"y":675,"fill":90,"style":106},"on lookup id",[73,698],{"x":699,"y":89,"width":665,"height":89,"rx":666,"fill":99,"stroke":111,"style":101},"560",[80,701,703],{"x":702,"y":671,"fill":84,"style":106},"635","HMAC compare",[80,705,706],{"x":702,"y":675,"fill":90,"style":106},"constant time",[117,708],{"x1":709,"y1":710,"x2":679,"y2":710,"stroke":90,"style":140},"170","105",[117,712],{"x1":713,"y1":710,"x2":689,"y2":710,"stroke":90,"style":140},"350",[117,715],{"x1":716,"y1":710,"x2":699,"y2":710,"stroke":90,"style":140},"530",[117,718],{"x1":702,"y1":719,"x2":702,"y2":720,"stroke":90,"style":140},"140","172",[117,722],{"x1":723,"y1":720,"x2":702,"y2":720,"stroke":90,"style":140},"305",[117,725],{"x1":723,"y1":720,"x2":723,"y2":143,"stroke":90,"style":140},[117,727],{"x1":716,"y1":720,"x2":716,"y2":143,"stroke":90,"style":140},[73,729],{"x":730,"y":143,"width":679,"height":731,"rx":666,"fill":99,"stroke":111,"style":101},"205","52",[80,733,735],{"x":723,"y":734,"fill":84,"style":106},"226","Match: row + tier",[73,737],{"x":144,"y":143,"width":679,"height":731,"rx":666,"fill":99,"stroke":122,"style":101},[80,739,740],{"x":716,"y":734,"fill":84,"style":106},"No match: 401",[80,742,744],{"x":110,"y":743,"fill":90,"style":106},"278","The raw key exists only in the request, never in your database",[14,746,747],{},"The table itself is small and boring, which is the point. Every key is a row; a customer with two live keys has two rows.",[188,749,751],{"className":190,"code":750,"language":192,"meta":193,"style":193},"# apikeys\u002Fschema.py\nimport asyncio\nimport os\n\nimport asyncpg\n\nDDL = \"\"\"\nCREATE TABLE IF NOT EXISTS api_keys (\n    lookup_id      text PRIMARY KEY,\n    key_hash       text NOT NULL,\n    customer_id    text NOT NULL,\n    status         text NOT NULL DEFAULT 'active',\n    label          text,\n    created_at     timestamptz NOT NULL DEFAULT now(),\n    expires_at     timestamptz,\n    last_used_at   timestamptz,\n    revoked_reason text\n);\nCREATE INDEX IF NOT EXISTS api_keys_customer_live\n    ON api_keys (customer_id) WHERE status IN ('active', 'retiring');\n\"\"\"\n\n\nasync def main() -> None:\n    conn = await asyncpg.connect(os.environ[\"DATABASE_URL\"])\n    try:\n        await conn.execute(DDL)\n    finally:\n        await conn.close()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n",[38,752,753,758,765,771,775,782,786,796,801,806,811,816,821,826,831,836,841,846,851,856,861,866,870,874,893,912,919,931,938,945,949,953,968],{"__ignoreMap":193},[197,754,755],{"class":117,"line":199},[197,756,757],{"class":202},"# apikeys\u002Fschema.py\n",[197,759,760,762],{"class":117,"line":206},[197,761,210],{"class":209},[197,763,764],{"class":213}," asyncio\n",[197,766,767,769],{"class":117,"line":217},[197,768,210],{"class":209},[197,770,230],{"class":213},[197,772,773],{"class":117,"line":225},[197,774,245],{"emptyLinePlaceholder":244},[197,776,777,779],{"class":117,"line":233},[197,778,210],{"class":209},[197,780,781],{"class":213}," asyncpg\n",[197,783,784],{"class":117,"line":241},[197,785,245],{"emptyLinePlaceholder":244},[197,787,788,791,793],{"class":117,"line":248},[197,789,790],{"class":251},"DDL",[197,792,255],{"class":209},[197,794,795],{"class":261}," \"\"\"\n",[197,797,798],{"class":117,"line":274},[197,799,800],{"class":261},"CREATE TABLE IF NOT EXISTS api_keys (\n",[197,802,803],{"class":117,"line":298},[197,804,805],{"class":261},"    lookup_id      text PRIMARY KEY,\n",[197,807,808],{"class":117,"line":303},[197,809,810],{"class":261},"    key_hash       text NOT NULL,\n",[197,812,813],{"class":117,"line":308},[197,814,815],{"class":261},"    customer_id    text NOT NULL,\n",[197,817,818],{"class":117,"line":332},[197,819,820],{"class":261},"    status         text NOT NULL DEFAULT 'active',\n",[197,822,823],{"class":117,"line":346},[197,824,825],{"class":261},"    label          text,\n",[197,827,828],{"class":117,"line":363},[197,829,830],{"class":261},"    created_at     timestamptz NOT NULL DEFAULT now(),\n",[197,832,833],{"class":117,"line":372},[197,834,835],{"class":261},"    expires_at     timestamptz,\n",[197,837,838],{"class":117,"line":381},[197,839,840],{"class":261},"    last_used_at   timestamptz,\n",[197,842,843],{"class":117,"line":387},[197,844,845],{"class":261},"    revoked_reason text\n",[197,847,848],{"class":117,"line":392},[197,849,850],{"class":261},");\n",[197,852,853],{"class":117,"line":397},[197,854,855],{"class":261},"CREATE INDEX IF NOT EXISTS api_keys_customer_live\n",[197,857,858],{"class":117,"line":421},[197,859,860],{"class":261},"    ON api_keys (customer_id) WHERE status IN ('active', 'retiring');\n",[197,862,863],{"class":117,"line":427},[197,864,865],{"class":261},"\"\"\"\n",[197,867,868],{"class":117,"line":447},[197,869,245],{"emptyLinePlaceholder":244},[197,871,872],{"class":117,"line":467},[197,873,245],{"emptyLinePlaceholder":244},[197,875,876,879,882,885,888,891],{"class":117,"line":508},[197,877,878],{"class":209},"async",[197,880,881],{"class":209}," def",[197,883,884],{"class":314}," main",[197,886,887],{"class":213},"() -> ",[197,889,890],{"class":251},"None",[197,892,329],{"class":213},[197,894,895,898,900,903,906,909],{"class":117,"line":516},[197,896,897],{"class":213},"    conn ",[197,899,433],{"class":209},[197,901,902],{"class":209}," await",[197,904,905],{"class":213}," asyncpg.connect(os.environ[",[197,907,908],{"class":261},"\"DATABASE_URL\"",[197,910,911],{"class":213},"])\n",[197,913,914,917],{"class":117,"line":521},[197,915,916],{"class":209},"    try",[197,918,329],{"class":213},[197,920,921,924,927,929],{"class":117,"line":526},[197,922,923],{"class":209},"        await",[197,925,926],{"class":213}," conn.execute(",[197,928,790],{"class":251},[197,930,271],{"class":213},[197,932,933,936],{"class":117,"line":559},[197,934,935],{"class":209},"    finally",[197,937,329],{"class":213},[197,939,940,942],{"class":117,"line":579},[197,941,923],{"class":209},[197,943,944],{"class":213}," conn.close()\n",[197,946,947],{"class":117,"line":606},[197,948,245],{"emptyLinePlaceholder":244},[197,950,951],{"class":117,"line":626},[197,952,245],{"emptyLinePlaceholder":244},[197,954,955,957,960,963,966],{"class":117,"line":634},[197,956,588],{"class":209},[197,958,959],{"class":251}," __name__",[197,961,962],{"class":209}," ==",[197,964,965],{"class":261}," \"__main__\"",[197,967,329],{"class":213},[197,969,971],{"class":117,"line":970},33,[197,972,973],{"class":213},"    asyncio.run(main())\n",[27,975],{},[30,977,979],{"id":978},"the-overlap-window-as-a-state-machine","The overlap window as a state machine",[14,981,982,983,986,987,990,991,994],{},"Three statuses carry the whole design. ",[38,984,985],{},"active"," means the key is the customer's current credential. ",[38,988,989],{},"retiring"," means it still authenticates but is on a clock — every response it serves carries a deadline header so a machine, not just a human, can notice. ",[38,992,993],{},"revoked"," means dead now, no grace, no appeal.",[14,996,997,998,1000,1001,1003,1004,1006,1007,1010,1011,1013],{},"Rotation is one transaction: insert a new ",[38,999,985],{}," row, flip the previous ",[38,1002,985],{}," row to ",[38,1005,989],{}," with an ",[38,1008,1009],{},"expires_at",". A leak skips the middle state entirely and goes straight to ",[38,1012,993],{},". That distinction is the entire reason to model status as a column instead of just deleting rows — planned rotation and incident response need different behaviour from the same table.",[54,1015,63,1020,63,1023,63,1026,63,1029,63,1032,63,1037,63,1042,63,1047,63,1053,63,1055,63,1058,63,1060,63,1062,63,1064,63,1067,63,1071,63,1074,63,1076,63,1081,63,1084,63,1087,63,1090],{"viewBox":1016,"role":57,"ariaLabelledBy":1017,"xmlns":61,"style":62},"0 0 720 260",[1018,1019],"rot-key-st-t","rot-key-st-d",[65,1021,1022],{"id":1018},"API key lifecycle state machine",[69,1024,1025],{"id":1019},"States run left to right from created to active to retiring to revoked, with captions describing what each state does to a request, plus a curved shortcut arrow from active straight to revoked labelled as a leak skipping the grace window.",[73,1027],{"x":75,"y":75,"width":76,"height":1028,"fill":78},"260",[80,1030,1031],{"x":82,"y":145,"fill":84,"style":85},"Key lifecycle",[1033,1034],"path",{"d":1035,"fill":1036,"stroke":122,"style":101},"M 270 100 C 270 40, 630 40, 630 100","none",[80,1038,1041],{"x":1039,"y":1040,"fill":84,"style":106},"450","46","Leak: skip the grace window",[73,1043],{"x":82,"y":1044,"width":719,"height":1045,"rx":1046,"fill":99,"stroke":667,"style":140},"100","56","10",[80,1048,1052],{"x":1049,"y":1050,"fill":84,"style":1051},"90","134","text-anchor:middle;font-size:13;font-family:var(--font-sans);","created",[73,1054],{"x":679,"y":1044,"width":719,"height":1045,"rx":1046,"fill":99,"stroke":111,"style":101},[80,1056,985],{"x":1057,"y":1050,"fill":84,"style":1051},"270",[73,1059],{"x":689,"y":1044,"width":665,"height":1045,"rx":1046,"fill":99,"stroke":138,"style":101},[80,1061,989],{"x":692,"y":1050,"fill":84,"style":1051},[73,1063],{"x":699,"y":1044,"width":719,"height":1045,"rx":1046,"fill":99,"stroke":122,"style":101},[80,1065,993],{"x":1066,"y":1050,"fill":84,"style":1051},"630",[117,1068],{"x1":1069,"y1":1070,"x2":679,"y2":1070,"stroke":90,"style":140},"160","128",[117,1072],{"x1":1073,"y1":1070,"x2":689,"y2":1070,"stroke":90,"style":140},"340",[117,1075],{"x1":716,"y1":1070,"x2":699,"y2":1070,"stroke":90,"style":140},[80,1077,1080],{"x":1049,"y":1078,"fill":90,"style":1079},"182","text-anchor:middle;font-size:11;font-family:var(--font-sans);","minted, unused",[80,1082,1083],{"x":1057,"y":1078,"fill":90,"style":1079},"served and metered",[80,1085,1086],{"x":692,"y":1078,"fill":90,"style":1079},"served + warn header",[80,1088,1089],{"x":1066,"y":1078,"fill":90,"style":1079},"401 immediately",[80,1091,1092],{"x":110,"y":734,"fill":84,"style":106},"A key reaches revoked when its traffic hits zero, or when a leak forces it",[14,1094,1095,1096,1098],{},"The verification dependency reads the status and decides. Note that a ",[38,1097,989],{}," key still returns 200 — it just tells the truth about its deadline in the response headers, so the customer's own monitoring can catch what their inbox missed.",[188,1100,1102],{"className":190,"code":1101,"language":192,"meta":193,"style":193},"# apikeys\u002Fverify.py\nimport hmac\nimport os\nfrom datetime import datetime, timezone\n\nfrom fastapi import HTTPException, Request, Response, status\n\nfrom .mint import hash_secret, split_key\n\nEXPIRES_HEADER = os.getenv(\"API_KEY_EXPIRES_HEADER\", \"X-Api-Key-Expires-At\")\n\n\nasync def current_key(request: Request, response: Response) -> dict:\n    raw = request.headers.get(\"authorization\", \"\").removeprefix(\"Bearer \").strip()\n    parts = split_key(raw)\n    if parts is None:\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Malformed API key\")\n    lookup_id, secret_part = parts\n\n    row = await request.app.state.pool.fetchrow(\n        \"SELECT lookup_id, key_hash, customer_id, status, expires_at \"\n        \"FROM api_keys WHERE lookup_id = $1\",\n        lookup_id,\n    )\n    if row is None or not hmac.compare_digest(row[\"key_hash\"], hash_secret(secret_part)):\n        raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Invalid API key\")\n\n    now = datetime.now(timezone.utc)\n    match (row[\"status\"], row[\"expires_at\"]):\n        case (\"revoked\", _):\n            raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Key revoked\")\n        case (\"retiring\", deadline) if deadline is not None and deadline \u003C= now:\n            raise HTTPException(status.HTTP_401_UNAUTHORIZED, \"Key expired\")\n        case (\"retiring\", deadline) if deadline is not None:\n            response.headers[EXPIRES_HEADER] = deadline.isoformat()\n            response.headers[\"Warning\"] = '299 - \"API key retiring, rotation incomplete\"'\n        case _:\n            pass\n    return dict(row)\n",[38,1103,1104,1109,1115,1121,1134,1138,1150,1154,1166,1170,1189,1193,1197,1214,1240,1250,1264,1281,1291,1295,1307,1312,1320,1325,1330,1355,1370,1374,1384,1403,1416,1432,1466,1481,1504,1519,1534,1541,1547],{"__ignoreMap":193},[197,1105,1106],{"class":117,"line":199},[197,1107,1108],{"class":202},"# apikeys\u002Fverify.py\n",[197,1110,1111,1113],{"class":117,"line":206},[197,1112,210],{"class":209},[197,1114,222],{"class":213},[197,1116,1117,1119],{"class":117,"line":217},[197,1118,210],{"class":209},[197,1120,230],{"class":213},[197,1122,1123,1126,1129,1131],{"class":117,"line":225},[197,1124,1125],{"class":209},"from",[197,1127,1128],{"class":213}," datetime ",[197,1130,210],{"class":209},[197,1132,1133],{"class":213}," datetime, timezone\n",[197,1135,1136],{"class":117,"line":233},[197,1137,245],{"emptyLinePlaceholder":244},[197,1139,1140,1142,1145,1147],{"class":117,"line":241},[197,1141,1125],{"class":209},[197,1143,1144],{"class":213}," fastapi ",[197,1146,210],{"class":209},[197,1148,1149],{"class":213}," HTTPException, Request, Response, status\n",[197,1151,1152],{"class":117,"line":248},[197,1153,245],{"emptyLinePlaceholder":244},[197,1155,1156,1158,1161,1163],{"class":117,"line":274},[197,1157,1125],{"class":209},[197,1159,1160],{"class":213}," .mint ",[197,1162,210],{"class":209},[197,1164,1165],{"class":213}," hash_secret, split_key\n",[197,1167,1168],{"class":117,"line":298},[197,1169,245],{"emptyLinePlaceholder":244},[197,1171,1172,1175,1177,1179,1182,1184,1187],{"class":117,"line":303},[197,1173,1174],{"class":251},"EXPIRES_HEADER",[197,1176,255],{"class":209},[197,1178,258],{"class":213},[197,1180,1181],{"class":261},"\"API_KEY_EXPIRES_HEADER\"",[197,1183,265],{"class":213},[197,1185,1186],{"class":261},"\"X-Api-Key-Expires-At\"",[197,1188,271],{"class":213},[197,1190,1191],{"class":117,"line":308},[197,1192,245],{"emptyLinePlaceholder":244},[197,1194,1195],{"class":117,"line":332},[197,1196,245],{"emptyLinePlaceholder":244},[197,1198,1199,1201,1203,1206,1209,1212],{"class":117,"line":346},[197,1200,878],{"class":209},[197,1202,881],{"class":209},[197,1204,1205],{"class":314}," current_key",[197,1207,1208],{"class":213},"(request: Request, response: Response) -> ",[197,1210,1211],{"class":251},"dict",[197,1213,329],{"class":213},[197,1215,1216,1219,1221,1224,1227,1229,1231,1234,1237],{"class":117,"line":363},[197,1217,1218],{"class":213},"    raw ",[197,1220,433],{"class":209},[197,1222,1223],{"class":213}," request.headers.get(",[197,1225,1226],{"class":261},"\"authorization\"",[197,1228,265],{"class":213},[197,1230,289],{"class":261},[197,1232,1233],{"class":213},").removeprefix(",[197,1235,1236],{"class":261},"\"Bearer \"",[197,1238,1239],{"class":213},").strip()\n",[197,1241,1242,1245,1247],{"class":117,"line":372},[197,1243,1244],{"class":213},"    parts ",[197,1246,433],{"class":209},[197,1248,1249],{"class":213}," split_key(raw)\n",[197,1251,1252,1254,1257,1260,1262],{"class":117,"line":381},[197,1253,335],{"class":209},[197,1255,1256],{"class":213}," parts ",[197,1258,1259],{"class":209},"is",[197,1261,554],{"class":251},[197,1263,329],{"class":213},[197,1265,1266,1268,1271,1274,1276,1279],{"class":117,"line":387},[197,1267,349],{"class":209},[197,1269,1270],{"class":213}," HTTPException(status.",[197,1272,1273],{"class":251},"HTTP_401_UNAUTHORIZED",[197,1275,265],{"class":213},[197,1277,1278],{"class":261},"\"Malformed API key\"",[197,1280,271],{"class":213},[197,1282,1283,1286,1288],{"class":117,"line":392},[197,1284,1285],{"class":213},"    lookup_id, secret_part ",[197,1287,433],{"class":209},[197,1289,1290],{"class":213}," parts\n",[197,1292,1293],{"class":117,"line":397},[197,1294,245],{"emptyLinePlaceholder":244},[197,1296,1297,1300,1302,1304],{"class":117,"line":421},[197,1298,1299],{"class":213},"    row ",[197,1301,433],{"class":209},[197,1303,902],{"class":209},[197,1305,1306],{"class":213}," request.app.state.pool.fetchrow(\n",[197,1308,1309],{"class":117,"line":427},[197,1310,1311],{"class":261},"        \"SELECT lookup_id, key_hash, customer_id, status, expires_at \"\n",[197,1313,1314,1317],{"class":117,"line":447},[197,1315,1316],{"class":261},"        \"FROM api_keys WHERE lookup_id = $1\"",[197,1318,1319],{"class":213},",\n",[197,1321,1322],{"class":117,"line":467},[197,1323,1324],{"class":213},"        lookup_id,\n",[197,1326,1327],{"class":117,"line":508},[197,1328,1329],{"class":213},"    )\n",[197,1331,1332,1334,1337,1339,1341,1344,1346,1349,1352],{"class":117,"line":516},[197,1333,335],{"class":209},[197,1335,1336],{"class":213}," row ",[197,1338,1259],{"class":209},[197,1340,554],{"class":251},[197,1342,1343],{"class":209}," or",[197,1345,338],{"class":209},[197,1347,1348],{"class":213}," hmac.compare_digest(row[",[197,1350,1351],{"class":261},"\"key_hash\"",[197,1353,1354],{"class":213},"], hash_secret(secret_part)):\n",[197,1356,1357,1359,1361,1363,1365,1368],{"class":117,"line":521},[197,1358,349],{"class":209},[197,1360,1270],{"class":213},[197,1362,1273],{"class":251},[197,1364,265],{"class":213},[197,1366,1367],{"class":261},"\"Invalid API key\"",[197,1369,271],{"class":213},[197,1371,1372],{"class":117,"line":526},[197,1373,245],{"emptyLinePlaceholder":244},[197,1375,1376,1379,1381],{"class":117,"line":559},[197,1377,1378],{"class":213},"    now ",[197,1380,433],{"class":209},[197,1382,1383],{"class":213}," datetime.now(timezone.utc)\n",[197,1385,1386,1388,1391,1394,1397,1400],{"class":117,"line":579},[197,1387,562],{"class":209},[197,1389,1390],{"class":213}," (row[",[197,1392,1393],{"class":261},"\"status\"",[197,1395,1396],{"class":213},"], row[",[197,1398,1399],{"class":261},"\"expires_at\"",[197,1401,1402],{"class":213},"]):\n",[197,1404,1405,1407,1410,1413],{"class":117,"line":606},[197,1406,582],{"class":209},[197,1408,1409],{"class":213}," (",[197,1411,1412],{"class":261},"\"revoked\"",[197,1414,1415],{"class":213},", _):\n",[197,1417,1418,1421,1423,1425,1427,1430],{"class":117,"line":626},[197,1419,1420],{"class":209},"            raise",[197,1422,1270],{"class":213},[197,1424,1273],{"class":251},[197,1426,265],{"class":213},[197,1428,1429],{"class":261},"\"Key revoked\"",[197,1431,271],{"class":213},[197,1433,1434,1436,1438,1441,1444,1446,1449,1451,1453,1455,1458,1460,1463],{"class":117,"line":634},[197,1435,582],{"class":209},[197,1437,1409],{"class":213},[197,1439,1440],{"class":261},"\"retiring\"",[197,1442,1443],{"class":213},", deadline) ",[197,1445,588],{"class":209},[197,1447,1448],{"class":213}," deadline ",[197,1450,1259],{"class":209},[197,1452,338],{"class":209},[197,1454,554],{"class":251},[197,1456,1457],{"class":209}," and",[197,1459,1448],{"class":213},[197,1461,1462],{"class":209},"\u003C=",[197,1464,1465],{"class":213}," now:\n",[197,1467,1468,1470,1472,1474,1476,1479],{"class":117,"line":970},[197,1469,1420],{"class":209},[197,1471,1270],{"class":213},[197,1473,1273],{"class":251},[197,1475,265],{"class":213},[197,1477,1478],{"class":261},"\"Key expired\"",[197,1480,271],{"class":213},[197,1482,1484,1486,1488,1490,1492,1494,1496,1498,1500,1502],{"class":117,"line":1483},34,[197,1485,582],{"class":209},[197,1487,1409],{"class":213},[197,1489,1440],{"class":261},[197,1491,1443],{"class":213},[197,1493,588],{"class":209},[197,1495,1448],{"class":213},[197,1497,1259],{"class":209},[197,1499,338],{"class":209},[197,1501,554],{"class":251},[197,1503,329],{"class":213},[197,1505,1507,1510,1512,1514,1516],{"class":117,"line":1506},35,[197,1508,1509],{"class":213},"            response.headers[",[197,1511,1174],{"class":251},[197,1513,548],{"class":213},[197,1515,433],{"class":209},[197,1517,1518],{"class":213}," deadline.isoformat()\n",[197,1520,1522,1524,1527,1529,1531],{"class":117,"line":1521},36,[197,1523,1509],{"class":213},[197,1525,1526],{"class":261},"\"Warning\"",[197,1528,548],{"class":213},[197,1530,433],{"class":209},[197,1532,1533],{"class":261}," '299 - \"API key retiring, rotation incomplete\"'\n",[197,1535,1537,1539],{"class":117,"line":1536},37,[197,1538,582],{"class":209},[197,1540,631],{"class":213},[197,1542,1544],{"class":117,"line":1543},38,[197,1545,1546],{"class":209},"            pass\n",[197,1548,1550,1552,1555],{"class":117,"line":1549},39,[197,1551,366],{"class":209},[197,1553,1554],{"class":251}," dict",[197,1556,1557],{"class":213},"(row)\n",[27,1559],{},[30,1561,1563],{"id":1562},"let-the-customer-rotate-themselves","Let the customer rotate themselves",[14,1565,1566,1567,46],{},"The rotation endpoint is what turns this from an internal runbook into a product feature. A customer who can rotate at 2am without emailing you is a customer who actually rotates, and self-service rotation is table stakes for anyone doing a SOC 2 review of your service. Ship it alongside the rest of ",[18,1568,1570],{"href":1569},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002F","your developer portal",[14,1572,1573],{},"Two rules matter. Authenticate the rotate call with the key being rotated — no separate credential to lose — and cap live keys per customer at two, so nobody accumulates a drawer of forgotten credentials. Return the raw key exactly once, in the response body, and never store it.",[188,1575,1577],{"className":190,"code":1576,"language":192,"meta":193,"style":193},"# apikeys\u002Froutes.py\nimport os\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi import APIRouter, Depends, HTTPException, Request, status\n\nfrom .mint import mint_key\nfrom .verify import current_key\n\nrouter = APIRouter(prefix=\"\u002Fv1\u002Fkeys\", tags=[\"keys\"])\nGRACE_HOURS = int(os.getenv(\"API_KEY_GRACE_HOURS\", \"168\"))\nMAX_LIVE_KEYS = int(os.getenv(\"API_KEY_MAX_LIVE\", \"2\"))\n\n\n@router.post(\"\u002Frotate\", status_code=status.HTTP_201_CREATED)\nasync def rotate(request: Request, key: dict = Depends(current_key)) -> dict:\n    raw, lookup_id, key_hash = mint_key()\n    deadline = datetime.now(timezone.utc) + timedelta(hours=GRACE_HOURS)\n    async with request.app.state.pool.acquire() as conn:\n        async with conn.transaction():\n            live = await conn.fetchval(\n                \"SELECT count(*) FROM api_keys \"\n                \"WHERE customer_id = $1 AND status IN ('active', 'retiring')\",\n                key[\"customer_id\"],\n            )\n            if live >= MAX_LIVE_KEYS:\n                raise HTTPException(status.HTTP_409_CONFLICT, \"Retire a key first\")\n            await conn.execute(\n                \"UPDATE api_keys SET status = 'retiring', expires_at = $2 \"\n                \"WHERE lookup_id = $1 AND status = 'active'\",\n                key[\"lookup_id\"], deadline,\n            )\n            await conn.execute(\n                \"INSERT INTO api_keys (lookup_id, key_hash, customer_id, status) \"\n                \"VALUES ($1, $2, $3, 'active')\",\n                lookup_id, key_hash, key[\"customer_id\"],\n            )\n    return {\"api_key\": raw, \"previous_key_expires_at\": deadline.isoformat()}\n",[38,1578,1579,1584,1590,1601,1605,1616,1620,1631,1643,1647,1681,1705,1726,1730,1734,1759,1782,1792,1817,1834,1844,1856,1861,1868,1879,1884,1900,1917,1925,1930,1937,1947,1951,1957,1962,1969,1978,1982],{"__ignoreMap":193},[197,1580,1581],{"class":117,"line":199},[197,1582,1583],{"class":202},"# apikeys\u002Froutes.py\n",[197,1585,1586,1588],{"class":117,"line":206},[197,1587,210],{"class":209},[197,1589,230],{"class":213},[197,1591,1592,1594,1596,1598],{"class":117,"line":217},[197,1593,1125],{"class":209},[197,1595,1128],{"class":213},[197,1597,210],{"class":209},[197,1599,1600],{"class":213}," datetime, timedelta, timezone\n",[197,1602,1603],{"class":117,"line":225},[197,1604,245],{"emptyLinePlaceholder":244},[197,1606,1607,1609,1611,1613],{"class":117,"line":233},[197,1608,1125],{"class":209},[197,1610,1144],{"class":213},[197,1612,210],{"class":209},[197,1614,1615],{"class":213}," APIRouter, Depends, HTTPException, Request, status\n",[197,1617,1618],{"class":117,"line":241},[197,1619,245],{"emptyLinePlaceholder":244},[197,1621,1622,1624,1626,1628],{"class":117,"line":248},[197,1623,1125],{"class":209},[197,1625,1160],{"class":213},[197,1627,210],{"class":209},[197,1629,1630],{"class":213}," mint_key\n",[197,1632,1633,1635,1638,1640],{"class":117,"line":274},[197,1634,1125],{"class":209},[197,1636,1637],{"class":213}," .verify ",[197,1639,210],{"class":209},[197,1641,1642],{"class":213}," current_key\n",[197,1644,1645],{"class":117,"line":298},[197,1646,245],{"emptyLinePlaceholder":244},[197,1648,1649,1652,1654,1657,1661,1663,1666,1668,1671,1673,1676,1679],{"class":117,"line":303},[197,1650,1651],{"class":213},"router ",[197,1653,433],{"class":209},[197,1655,1656],{"class":213}," APIRouter(",[197,1658,1660],{"class":1659},"s4XuR","prefix",[197,1662,433],{"class":209},[197,1664,1665],{"class":261},"\"\u002Fv1\u002Fkeys\"",[197,1667,265],{"class":213},[197,1669,1670],{"class":1659},"tags",[197,1672,433],{"class":209},[197,1674,1675],{"class":213},"[",[197,1677,1678],{"class":261},"\"keys\"",[197,1680,911],{"class":213},[197,1682,1683,1686,1688,1691,1694,1697,1699,1702],{"class":117,"line":308},[197,1684,1685],{"class":251},"GRACE_HOURS",[197,1687,255],{"class":209},[197,1689,1690],{"class":251}," int",[197,1692,1693],{"class":213},"(os.getenv(",[197,1695,1696],{"class":261},"\"API_KEY_GRACE_HOURS\"",[197,1698,265],{"class":213},[197,1700,1701],{"class":261},"\"168\"",[197,1703,1704],{"class":213},"))\n",[197,1706,1707,1710,1712,1714,1716,1719,1721,1724],{"class":117,"line":332},[197,1708,1709],{"class":251},"MAX_LIVE_KEYS",[197,1711,255],{"class":209},[197,1713,1690],{"class":251},[197,1715,1693],{"class":213},[197,1717,1718],{"class":261},"\"API_KEY_MAX_LIVE\"",[197,1720,265],{"class":213},[197,1722,1723],{"class":261},"\"2\"",[197,1725,1704],{"class":213},[197,1727,1728],{"class":117,"line":346},[197,1729,245],{"emptyLinePlaceholder":244},[197,1731,1732],{"class":117,"line":363},[197,1733,245],{"emptyLinePlaceholder":244},[197,1735,1736,1739,1741,1744,1746,1749,1751,1754,1757],{"class":117,"line":372},[197,1737,1738],{"class":314},"@router.post",[197,1740,355],{"class":213},[197,1742,1743],{"class":261},"\"\u002Frotate\"",[197,1745,265],{"class":213},[197,1747,1748],{"class":1659},"status_code",[197,1750,433],{"class":209},[197,1752,1753],{"class":213},"status.",[197,1755,1756],{"class":251},"HTTP_201_CREATED",[197,1758,271],{"class":213},[197,1760,1761,1763,1765,1768,1771,1773,1775,1778,1780],{"class":117,"line":381},[197,1762,878],{"class":209},[197,1764,881],{"class":209},[197,1766,1767],{"class":314}," rotate",[197,1769,1770],{"class":213},"(request: Request, key: ",[197,1772,1211],{"class":251},[197,1774,255],{"class":209},[197,1776,1777],{"class":213}," Depends(current_key)) -> ",[197,1779,1211],{"class":251},[197,1781,329],{"class":213},[197,1783,1784,1787,1789],{"class":117,"line":387},[197,1785,1786],{"class":213},"    raw, lookup_id, key_hash ",[197,1788,433],{"class":209},[197,1790,1791],{"class":213}," mint_key()\n",[197,1793,1794,1797,1799,1802,1805,1808,1811,1813,1815],{"class":117,"line":392},[197,1795,1796],{"class":213},"    deadline ",[197,1798,433],{"class":209},[197,1800,1801],{"class":213}," datetime.now(timezone.utc) ",[197,1803,1804],{"class":209},"+",[197,1806,1807],{"class":213}," timedelta(",[197,1809,1810],{"class":1659},"hours",[197,1812,433],{"class":209},[197,1814,1685],{"class":251},[197,1816,271],{"class":213},[197,1818,1819,1822,1825,1828,1831],{"class":117,"line":397},[197,1820,1821],{"class":209},"    async",[197,1823,1824],{"class":209}," with",[197,1826,1827],{"class":213}," request.app.state.pool.acquire() ",[197,1829,1830],{"class":209},"as",[197,1832,1833],{"class":213}," conn:\n",[197,1835,1836,1839,1841],{"class":117,"line":421},[197,1837,1838],{"class":209},"        async",[197,1840,1824],{"class":209},[197,1842,1843],{"class":213}," conn.transaction():\n",[197,1845,1846,1849,1851,1853],{"class":117,"line":427},[197,1847,1848],{"class":213},"            live ",[197,1850,433],{"class":209},[197,1852,902],{"class":209},[197,1854,1855],{"class":213}," conn.fetchval(\n",[197,1857,1858],{"class":117,"line":447},[197,1859,1860],{"class":261},"                \"SELECT count(*) FROM api_keys \"\n",[197,1862,1863,1866],{"class":117,"line":467},[197,1864,1865],{"class":261},"                \"WHERE customer_id = $1 AND status IN ('active', 'retiring')\"",[197,1867,1319],{"class":213},[197,1869,1870,1873,1876],{"class":117,"line":508},[197,1871,1872],{"class":213},"                key[",[197,1874,1875],{"class":261},"\"customer_id\"",[197,1877,1878],{"class":213},"],\n",[197,1880,1881],{"class":117,"line":516},[197,1882,1883],{"class":213},"            )\n",[197,1885,1886,1889,1892,1895,1898],{"class":117,"line":521},[197,1887,1888],{"class":209},"            if",[197,1890,1891],{"class":213}," live ",[197,1893,1894],{"class":209},">=",[197,1896,1897],{"class":251}," MAX_LIVE_KEYS",[197,1899,329],{"class":213},[197,1901,1902,1905,1907,1910,1912,1915],{"class":117,"line":526},[197,1903,1904],{"class":209},"                raise",[197,1906,1270],{"class":213},[197,1908,1909],{"class":251},"HTTP_409_CONFLICT",[197,1911,265],{"class":213},[197,1913,1914],{"class":261},"\"Retire a key first\"",[197,1916,271],{"class":213},[197,1918,1919,1922],{"class":117,"line":559},[197,1920,1921],{"class":209},"            await",[197,1923,1924],{"class":213}," conn.execute(\n",[197,1926,1927],{"class":117,"line":579},[197,1928,1929],{"class":261},"                \"UPDATE api_keys SET status = 'retiring', expires_at = $2 \"\n",[197,1931,1932,1935],{"class":117,"line":606},[197,1933,1934],{"class":261},"                \"WHERE lookup_id = $1 AND status = 'active'\"",[197,1936,1319],{"class":213},[197,1938,1939,1941,1944],{"class":117,"line":626},[197,1940,1872],{"class":213},[197,1942,1943],{"class":261},"\"lookup_id\"",[197,1945,1946],{"class":213},"], deadline,\n",[197,1948,1949],{"class":117,"line":634},[197,1950,1883],{"class":213},[197,1952,1953,1955],{"class":117,"line":970},[197,1954,1921],{"class":209},[197,1956,1924],{"class":213},[197,1958,1959],{"class":117,"line":1483},[197,1960,1961],{"class":261},"                \"INSERT INTO api_keys (lookup_id, key_hash, customer_id, status) \"\n",[197,1963,1964,1967],{"class":117,"line":1506},[197,1965,1966],{"class":261},"                \"VALUES ($1, $2, $3, 'active')\"",[197,1968,1319],{"class":213},[197,1970,1971,1974,1976],{"class":117,"line":1521},[197,1972,1973],{"class":213},"                lookup_id, key_hash, key[",[197,1975,1875],{"class":261},[197,1977,1878],{"class":213},[197,1979,1980],{"class":117,"line":1536},[197,1981,1883],{"class":213},[197,1983,1984,1986,1989,1992,1995,1998],{"class":117,"line":1543},[197,1985,366],{"class":209},[197,1987,1988],{"class":213}," {",[197,1990,1991],{"class":261},"\"api_key\"",[197,1993,1994],{"class":213},": raw, ",[197,1996,1997],{"class":261},"\"previous_key_expires_at\"",[197,1999,2000],{"class":213},": deadline.isoformat()}\n",[14,2002,2003,2004,2008,2009,2012],{},"Watch the old key's traffic decay after each rotation — that curve is your real signal for whether the grace window is long enough. Record it from the same event stream you use for ",[18,2005,2007],{"href":2006},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Flogging-api-usage-events-to-postgres\u002F","logging API usage events to Postgres","; a ",[38,2010,2011],{},"last_used_at"," touch on the key row costs almost nothing if you batch it.",[54,2014,63,2018,63,2021,63,2024,63,2026,63,2029,63,2033,63,2039,63,2043,63,2047,63,2050,63,2054,63,2058,63,2062,63,2068,63,2072,63,2074,63,2079,63,2083,63,2088,63,2092,63,2096,63,2100,63,2103,63,2106,63,2109,63,2112,63,2115],{"viewBox":56,"role":57,"ariaLabelledBy":2015,"xmlns":61,"style":62},[2016,2017],"rot-key-ch-t","rot-key-ch-d",[65,2019,2020],{"id":2016},"Traffic decay on a retiring API key",[69,2022,2023],{"id":2017},"A bar chart of the share of requests still using the old key after a rotation: one hundred percent on day zero, sixty-two on day one, twenty-eight on day three, nine on day seven, three on day ten and under one percent on day fourteen.",[73,2025],{"x":75,"y":75,"width":76,"height":77,"fill":78},[80,2027,2028],{"x":82,"y":83,"fill":84,"style":85},"Share of calls still using the old key",[117,2030],{"x1":89,"y1":2031,"x2":2032,"y2":2031,"stroke":667,"style":140},"230","690",[80,2034,2038],{"x":2035,"y":2036,"fill":90,"style":2037},"60","64","text-anchor:end;font-size:11;font-family:var(--font-sans);","100%",[80,2040,2042],{"x":2035,"y":2041,"fill":90,"style":2037},"149","50%",[80,2044,2046],{"x":2035,"y":2045,"fill":90,"style":2037},"234","0%",[73,2048],{"x":1049,"y":2035,"width":2035,"height":709,"rx":2049,"fill":100},"3",[73,2051],{"x":2052,"y":2053,"width":2035,"height":710,"rx":2049,"fill":100},"190","125",[73,2055],{"x":2056,"y":1078,"width":2035,"height":2057,"rx":2049,"fill":100},"290","48",[73,2059],{"x":161,"y":2060,"width":2035,"height":2061,"rx":2049,"fill":111},"215","15",[73,2063],{"x":2064,"y":2065,"width":2035,"height":2066,"rx":2067,"fill":111},"490","225","5","2",[73,2069],{"x":2070,"y":2071,"width":2035,"height":2067,"rx":573,"fill":111},"590","228",[80,2073,1044],{"x":135,"y":731,"fill":84,"style":1079},[80,2075,2078],{"x":2076,"y":2077,"fill":84,"style":1079},"220","117","62",[80,2080,83],{"x":2081,"y":2082,"fill":84,"style":1079},"320","174",[80,2084,2087],{"x":2085,"y":2086,"fill":84,"style":1079},"420","207","9",[80,2089,2049],{"x":2090,"y":2091,"fill":84,"style":1079},"520","217",[80,2093,2095],{"x":2094,"y":2076,"fill":84,"style":1079},"620","0.4",[80,2097,2099],{"x":135,"y":2098,"fill":90,"style":1079},"250","day 0",[80,2101,2102],{"x":2076,"y":2098,"fill":90,"style":1079},"day 1",[80,2104,2105],{"x":2081,"y":2098,"fill":90,"style":1079},"day 3",[80,2107,2108],{"x":2085,"y":2098,"fill":90,"style":1079},"day 7",[80,2110,2111],{"x":2090,"y":2098,"fill":90,"style":1079},"day 10",[80,2113,2114],{"x":2094,"y":2098,"fill":90,"style":1079},"day 14",[80,2116,2117],{"x":689,"y":743,"fill":84,"style":106},"Days since the customer called \u002Fv1\u002Fkeys\u002Frotate",[27,2119],{},[30,2121,2123],{"id":2122},"revoking-a-leaked-key-immediately","Revoking a leaked key immediately",[14,2125,2126],{},"Everything above assumes a planned rotation. A leak inverts the priority: breaking the customer's integration is now the correct outcome, because the alternative is an attacker running up their metered usage on your infrastructure. Revocation must be immediate and global.",[14,2128,2129,2130,2134],{},"The trap here is your own cache. Any sane key check caches the verified row — ",[18,2131,2133],{"href":2132},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","caching API responses with Redis"," applies just as much to auth lookups, since it is the difference between one database round trip per request and one per minute. A 60-second cache means a revoked key keeps working for up to 60 seconds on every instance. Delete the cache entry in the same call that flips the row, and publish the revocation so instances holding in-process copies drop them too.",[188,2136,2138],{"className":190,"code":2137,"language":192,"meta":193,"style":193},"# apikeys\u002Frevoke.py\nimport os\n\nimport redis.asyncio as redis\n\nCACHE_URL = os.getenv(\"REDIS_URL\", \"redis:\u002F\u002Flocalhost:6379\u002F0\")\nCACHE_PREFIX = os.getenv(\"API_KEY_CACHE_PREFIX\", \"apikey:\")\nREVOKE_CHANNEL = os.getenv(\"API_KEY_REVOKE_CHANNEL\", \"key-revocations\")\n\n\nasync def revoke_now(pool, lookup_id: str, reason: str) -> None:\n    \"\"\"Kill a key everywhere: database row, shared cache, and in-process copies.\"\"\"\n    await pool.execute(\n        \"UPDATE api_keys SET status = 'revoked', expires_at = now(), \"\n        \"revoked_reason = $2 WHERE lookup_id = $1\",\n        lookup_id, reason,\n    )\n    client = redis.from_url(CACHE_URL)\n    try:\n        await client.delete(f\"{CACHE_PREFIX}{lookup_id}\")\n        await client.publish(REVOKE_CHANNEL, lookup_id)\n    finally:\n        await client.aclose()\n",[38,2139,2140,2145,2151,2155,2167,2171,2190,2209,2228,2232,2236,2261,2266,2274,2279,2286,2291,2295,2309,2315,2337,2349,2355],{"__ignoreMap":193},[197,2141,2142],{"class":117,"line":199},[197,2143,2144],{"class":202},"# apikeys\u002Frevoke.py\n",[197,2146,2147,2149],{"class":117,"line":206},[197,2148,210],{"class":209},[197,2150,230],{"class":213},[197,2152,2153],{"class":117,"line":217},[197,2154,245],{"emptyLinePlaceholder":244},[197,2156,2157,2159,2162,2164],{"class":117,"line":225},[197,2158,210],{"class":209},[197,2160,2161],{"class":213}," redis.asyncio ",[197,2163,1830],{"class":209},[197,2165,2166],{"class":213}," redis\n",[197,2168,2169],{"class":117,"line":233},[197,2170,245],{"emptyLinePlaceholder":244},[197,2172,2173,2176,2178,2180,2183,2185,2188],{"class":117,"line":241},[197,2174,2175],{"class":251},"CACHE_URL",[197,2177,255],{"class":209},[197,2179,258],{"class":213},[197,2181,2182],{"class":261},"\"REDIS_URL\"",[197,2184,265],{"class":213},[197,2186,2187],{"class":261},"\"redis:\u002F\u002Flocalhost:6379\u002F0\"",[197,2189,271],{"class":213},[197,2191,2192,2195,2197,2199,2202,2204,2207],{"class":117,"line":248},[197,2193,2194],{"class":251},"CACHE_PREFIX",[197,2196,255],{"class":209},[197,2198,258],{"class":213},[197,2200,2201],{"class":261},"\"API_KEY_CACHE_PREFIX\"",[197,2203,265],{"class":213},[197,2205,2206],{"class":261},"\"apikey:\"",[197,2208,271],{"class":213},[197,2210,2211,2214,2216,2218,2221,2223,2226],{"class":117,"line":274},[197,2212,2213],{"class":251},"REVOKE_CHANNEL",[197,2215,255],{"class":209},[197,2217,258],{"class":213},[197,2219,2220],{"class":261},"\"API_KEY_REVOKE_CHANNEL\"",[197,2222,265],{"class":213},[197,2224,2225],{"class":261},"\"key-revocations\"",[197,2227,271],{"class":213},[197,2229,2230],{"class":117,"line":298},[197,2231,245],{"emptyLinePlaceholder":244},[197,2233,2234],{"class":117,"line":303},[197,2235,245],{"emptyLinePlaceholder":244},[197,2237,2238,2240,2242,2245,2248,2250,2253,2255,2257,2259],{"class":117,"line":308},[197,2239,878],{"class":209},[197,2241,881],{"class":209},[197,2243,2244],{"class":314}," revoke_now",[197,2246,2247],{"class":213},"(pool, lookup_id: ",[197,2249,321],{"class":251},[197,2251,2252],{"class":213},", reason: ",[197,2254,321],{"class":251},[197,2256,324],{"class":213},[197,2258,890],{"class":251},[197,2260,329],{"class":213},[197,2262,2263],{"class":117,"line":332},[197,2264,2265],{"class":261},"    \"\"\"Kill a key everywhere: database row, shared cache, and in-process copies.\"\"\"\n",[197,2267,2268,2271],{"class":117,"line":346},[197,2269,2270],{"class":209},"    await",[197,2272,2273],{"class":213}," pool.execute(\n",[197,2275,2276],{"class":117,"line":363},[197,2277,2278],{"class":261},"        \"UPDATE api_keys SET status = 'revoked', expires_at = now(), \"\n",[197,2280,2281,2284],{"class":117,"line":372},[197,2282,2283],{"class":261},"        \"revoked_reason = $2 WHERE lookup_id = $1\"",[197,2285,1319],{"class":213},[197,2287,2288],{"class":117,"line":381},[197,2289,2290],{"class":213},"        lookup_id, reason,\n",[197,2292,2293],{"class":117,"line":387},[197,2294,1329],{"class":213},[197,2296,2297,2300,2302,2305,2307],{"class":117,"line":392},[197,2298,2299],{"class":213},"    client ",[197,2301,433],{"class":209},[197,2303,2304],{"class":213}," redis.from_url(",[197,2306,2175],{"class":251},[197,2308,271],{"class":213},[197,2310,2311,2313],{"class":117,"line":397},[197,2312,916],{"class":209},[197,2314,329],{"class":213},[197,2316,2317,2319,2322,2324,2326,2329,2331,2333,2335],{"class":117,"line":421},[197,2318,923],{"class":209},[197,2320,2321],{"class":213}," client.delete(",[197,2323,594],{"class":209},[197,2325,478],{"class":261},[197,2327,2328],{"class":251},"{CACHE_PREFIX}{",[197,2330,490],{"class":213},[197,2332,493],{"class":251},[197,2334,478],{"class":261},[197,2336,271],{"class":213},[197,2338,2339,2341,2344,2346],{"class":117,"line":427},[197,2340,923],{"class":209},[197,2342,2343],{"class":213}," client.publish(",[197,2345,2213],{"class":251},[197,2347,2348],{"class":213},", lookup_id)\n",[197,2350,2351,2353],{"class":117,"line":447},[197,2352,935],{"class":209},[197,2354,329],{"class":213},[197,2356,2357,2359],{"class":117,"line":467},[197,2358,923],{"class":209},[197,2360,2361],{"class":213}," client.aclose()\n",[14,2363,2364,2365,2369],{},"Emit a structured event on every revocation with the reason and the actor, using the pattern from ",[18,2366,2368],{"href":2367},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging with structlog",". When a customer asks six months later why their key died, the answer needs to be a log line, not a guess.",[27,2371],{},[30,2373,2375],{"id":2374},"when-to-overlap-and-when-not-to","When to overlap, and when not to",[14,2377,2378],{},"Overlap is right for essentially every planned rotation. It is wrong when the key is known-compromised, when a customer's contract ends, or when an employee with access to the credential leaves under a cloud. In those cases the failed requests are the feature.",[2380,2381,2382,2398],"table",{},[2383,2384,2385],"thead",{},[2386,2387,2388,2392,2395],"tr",{},[2389,2390,2391],"th",{},"Situation",[2389,2393,2394],{},"Window",[2389,2396,2397],{},"Rationale",[2399,2400,2401,2413,2424,2435],"tbody",{},[2386,2402,2403,2407,2410],{},[2404,2405,2406],"td",{},"Scheduled annual rotation",[2404,2408,2409],{},"30 days",[2404,2411,2412],{},"Fits a customer sprint cycle",[2386,2414,2415,2418,2421],{},[2404,2416,2417],{},"Self-service rotation",[2404,2419,2420],{},"7 days",[2404,2422,2423],{},"Default; covers a weekend plus slack",[2386,2425,2426,2429,2432],{},[2404,2427,2428],{},"Suspicious activity",[2404,2430,2431],{},"1 hour",[2404,2433,2434],{},"Enough to redeploy, not to abuse",[2386,2436,2437,2440,2442],{},[2404,2438,2439],{},"Confirmed leak",[2404,2441,890],{},[2404,2443,2444],{},"Revoke instantly, notify after",[14,2446,2447],{},"Traffic level shifts the default. A customer at ten requests a day may not exercise every code path in a week, so lengthen the window until you have seen zero old-key traffic for at least three of their natural cycles. A customer at a thousand requests a minute proves migration within hours, and holding the old key open beyond that just widens the exposure. Cost is not the constraint here — an extra row and one index entry per customer is free — so pick the window from risk, not from storage.",[27,2449],{},[30,2451,2453],{"id":2452},"migrating-from-a-single-plaintext-key-column","Migrating from a single plaintext key column",[14,2455,2456,2457,2460,2461,2464],{},"If you already shipped ",[38,2458,2459],{},"customers.api_key"," as a plaintext column, the switch takes four steps and no downtime. First, create the ",[38,2462,2463],{},"api_keys"," table and start writing new keys to it while verification checks both places, old column first for a miss. Second, backfill: for every existing customer, insert a row with the lookup id derived from the existing key and the hash computed from its secret half — if your current keys have no dot-separated structure, mint fresh keys instead and run the overlap from that moment. Third, announce and rotate: call the rotation flow for each customer, keeping the legacy column valid as the retiring credential. Fourth, once old-key traffic is zero, drop the column.",[14,2466,2467,2468,2472,2473,2477],{},"The same rollout discipline covered in ",[18,2469,2471],{"href":2470},"\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"," applies: dual-read before you dual-write, and never remove the old path in the same release that adds the new one. Cover both branches with tests before you touch production, using the approach in ",[18,2474,2476],{"href":2475},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","testing Python APIs with pytest"," — a rotation bug is an outage that authenticates as your customer.",[27,2479],{},[30,2481,2483],{"id":2482},"builder-verdict","Builder verdict",[14,2485,2486],{},"Ship the dual-key overlap with a seven-day default and a self-service rotate endpoint, and never write an atomic key swap. The extra work is roughly one table, one endpoint and a status check in the dependency you already have — call it an afternoon. What you get back is the ability to respond to a leaked credential in seconds without arguing about blast radius, a compliance answer that closes enterprise deals, and zero support tickets from planned rotations. The atomic swap looks cheaper only until the first time you use it; one auth outage on a paying customer costs more in refunds, credibility and engineering time than the overlap pattern costs to build twice over. Build it before your first enterprise customer asks, because they will ask, and \"we are working on it\" is a worse answer than a link to your docs.",[27,2488],{},[30,2490,2492],{"id":2491},"faq","FAQ",[14,2494,2495,2499],{},[2496,2497,2498],"strong",{},"How long should the grace window be before it becomes a security risk?","\nSeven days is the sweet spot for self-service rotation and thirty for a scheduled policy rotation. Risk scales with the window only if you assume the old key is already compromised — and if you assume that, you should revoke instead of retire. Keep the window, but watch the old key's traffic and close it early once it hits zero.",[14,2501,2502,2505],{},[2496,2503,2504],{},"Does an extra key row per customer cost anything meaningful at scale?","\nNo. At 10,000 customers each holding two keys you are storing 20,000 rows of roughly 200 bytes — four megabytes, entirely cached in memory by Postgres. Verification is one index hit either way. The only real cost is the HMAC, which runs in microseconds; if you use bcrypt instead you will pay in extra instances.",[14,2507,2508,2511,2512,2515,2516,2518,2519,2521],{},[2496,2509,2510],{},"What happens to metered billing during the overlap?","\nBill the customer, not the key. Attribute usage to ",[38,2513,2514],{},"customer_id"," so a rotation never splits an invoice or resets a quota. If you also want per-key visibility for the customer's own debugging, log ",[38,2517,490],{}," alongside ",[38,2520,2514],{}," on each usage event and aggregate on the customer for billing.",[14,2523,2524,2527,2528,2530],{},[2496,2525,2526],{},"Can I force rotation on a schedule without breaking customers?","\nYes, if you announce it in-band. Set ",[38,2529,1009],{}," at issue time, return it in the response headers on every call, and send a webhook or email at the 30-, 7- and 1-day marks. Customers who ignore all of that will still break, which is why the header matters: their monitoring can fail the build before your API fails their production.",[14,2532,2533,2536,2537,2541],{},[2496,2534,2535],{},"How do I roll this out without a maintenance window?","\nDeploy verification that accepts both the old and the new storage format first, backfill, then rotate customer by customer, then remove the old path in a later release. That is the same staged pattern used when ",[18,2538,2540],{"href":2539},"\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"," — never flip reads and writes in one deploy.",[27,2543],{},[30,2545,2547],{"id":2546},"related","Related",[14,2549,2550],{},[2496,2551,2552],{},"Same track:",[2554,2555,2556,2562,2569],"ul",{},[2557,2558,2559,2561],"li",{},[18,2560,21],{"href":20}," — the parent guide covering how keys get issued and verified in the first place.",[2557,2563,2564,2568],{},[18,2565,2567],{"href":2566},"\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"," — why revocable keys beat stateless tokens when rotation matters.",[2557,2570,2571,2574],{},[18,2572,2573],{"href":44},"Debugging 401 Unauthorized API errors"," — what your customers see when a rotation goes wrong.",[14,2576,2577],{},[2496,2578,2579],{},"Other tracks:",[2554,2581,2582,2588],{},[2557,2583,2584,2587],{},[18,2585,2586],{"href":1569},"Creating a developer portal for your API"," — where the self-service rotate button actually lives.",[2557,2589,2590,2593],{},[18,2591,2592],{"href":2006},"Logging API usage events to Postgres"," — the event stream that tells you when an old key is finally idle.",[2595,2596,2597],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":193,"searchDepth":206,"depth":206,"links":2599},[2600,2601,2602,2603,2604,2605,2606,2607,2608,2609],{"id":32,"depth":206,"text":33},{"id":172,"depth":206,"text":173},{"id":978,"depth":206,"text":979},{"id":1562,"depth":206,"text":1563},{"id":2122,"depth":206,"text":2123},{"id":2374,"depth":206,"text":2375},{"id":2452,"depth":206,"text":2453},{"id":2482,"depth":206,"text":2483},{"id":2491,"depth":206,"text":2492},{"id":2546,"depth":206,"text":2547},"Rotate API keys with zero failed requests using dual-key overlap windows, hashed key storage, grace periods, customer rotation endpoints and instant revocation.","md",{"pageTitle":2613,"type":2614,"datePublished":2615,"dateModified":2615},"Rotating API Keys Without Downtime: The Dual-Key Pattern","article","2026-07-23","\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime",{"title":5,"description":2610},"getting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002Findex","u-LPS9AB6LLCMvFXR8rCt3EWbGfG01RLS_WQoK0OsA0",{"@context":2621,"@type":2622,"mainEntity":2623},"https:\u002F\u002Fschema.org","FAQPage",[2624,2629,2632,2635,2638],{"@type":2625,"name":2498,"acceptedAnswer":2626},"Question",{"@type":2627,"text":2628},"Answer","Seven days is the sweet spot for self-service rotation and thirty for a scheduled policy rotation. Risk scales with the window only if you assume the old key is already compromised — and if you assume that, you should revoke instead of retire. Keep the window, but watch the old key's traffic and close it early once it hits zero.",{"@type":2625,"name":2504,"acceptedAnswer":2630},{"@type":2627,"text":2631},"No. At 10,000 customers each holding two keys you are storing 20,000 rows of roughly 200 bytes — four megabytes, entirely cached in memory by Postgres. Verification is one index hit either way. The only real cost is the HMAC, which runs in microseconds; if you use bcrypt instead you will pay in extra instances.",{"@type":2625,"name":2510,"acceptedAnswer":2633},{"@type":2627,"text":2634},"Bill the customer, not the key. Attribute usage to customer_id so a rotation never splits an invoice or resets a quota. If you also want per-key visibility for the customer's own debugging, log lookup_id alongside customer_id on each usage event and aggregate on the customer for billing.",{"@type":2625,"name":2526,"acceptedAnswer":2636},{"@type":2627,"text":2637},"Yes, if you announce it in-band. Set expires_at at issue time, return it in the response headers on every call, and send a webhook or email at the 30-, 7- and 1-day marks. Customers who ignore all of that will still break, which is why the header matters: their monitoring can fail the build before your API fails their production.",{"@type":2625,"name":2535,"acceptedAnswer":2639},{"@type":2627,"text":2640},"Deploy verification that accepts both the old and the new storage format first, backfill, then rotate customer by customer, then remove the old path in a later release. That is the same staged pattern used when deprecating an API endpoint without breaking customers — never flip reads and writes in one deploy. ---",1784887028358]