sethserver.com
A row of brass keys with distinct colored tags hanging from a hook board, one key casting a shadow shaped like a fingerprint, on a clean slate gray wall.

API Key Generator with Prefix, Checksum and SHA-256

By Seth Black • Updated: September 17, 2026

Your API key

generating...

SHA-256 of the key (store this, not the key)

...

Generated in your browser with crypto.getRandomValues; the hash comes from crypto.subtle. Nothing leaves the page.

Most "API key generators" give you a long random string and wish you luck. The random string is the easy part. The parts that decide whether your API keys are pleasant or a liability are the format around it and what your server does with it. This page generates keys in a format I would actually ship, and the rest of the page explains each choice.

Anatomy of a good API key

A key from the generator above looks like this (the spaces are only here to show the parts):

sk_live_   8Kq2mZ0xR4...vT7wYc   3fQ9aB
prefix     random part           checksum

The prefix

The prefix says what the key is before anyone looks anything up. Stripe is the familiar example: secret keys start with sk_live_ or sk_test_, publishable keys with pk_, restricted keys with rk_. When a customer pastes a key into a support ticket, you know immediately whether it is a test key or a live one, and so does your code.

The bigger win is secret scanning. A pattern like sk_live_ followed by a fixed number of base62 characters is easy for a scanner to match with very few false positives. When GitHub moved its tokens to a prefixed format (ghp_ for personal access tokens, gho_ for OAuth tokens, and so on), it wrote that the prefix alone was expected to cut secret scanning false positives to about 0.5 percent. GitHub also chose the underscore on purpose: it is not a base64 character, and double-clicking the token selects the whole thing.

The prefix is not secret and adds no entropy. It is a label. Keep it short, and include the environment if you have more than one.

The random part

This is the actual secret. It needs to come from a cryptographically secure random number generator, and it needs enough bits that guessing is hopeless. The generator draws each base62 character with SecureRandom.int(62), which uses rejection sampling, so every character is exactly equally likely. (The lazy version, a random byte modulo 62, makes the first eight characters slightly more likely than the rest.)

Entropybase62 charsbase64url charshex chars
128 bits22 (about 131 bits)22 (16 bytes)32 (16 bytes)
192 bits33 (about 196 bits)32 (24 bytes)48 (24 bytes)
256 bits43 (about 256 bits)43 (32 bytes)64 (32 bytes)

Each base62 character carries about 5.95 bits, so the page rounds the character count up and reports the real total. My pick is base62: it is compact, it survives URLs, JSON, shells and spreadsheets without escaping, and a double-click selects the whole key. base64url is just as compact, but its - breaks double-click selection. Hex is the longest and the easiest to read aloud.

The checksum

With the box ticked, the generator computes a CRC32 of the random part and appends it as 6 base62 characters (a CRC32 is at most 4,294,967,295, and 62 to the sixth power is bigger than that, so 6 characters always fit). GitHub's token format does the same thing: a 32 bit CRC32 checksum, base62 encoded, in the last 6 characters.

That buys you two things:

What it does not buy you is security. CRC32 is error detection. Anyone can compute it, so never treat a valid checksum as proof that a key is genuine. That is what the hash lookup is for.

Store the hash, never the key

Treat API keys like passwords: the server keeps a hash and throws the key away after showing it once. If your database leaks (a backup, a replica, a SQL injection bug, see how to prevent SQL injection), the attacker gets hashes they cannot use to call your API.

For API keys, plain SHA-256 is fine and usual. bcrypt and Argon2 are slow on purpose because human passwords are guessable. A key with 128 or more random bits is not, so a slow hash only makes every authenticated request slower. The SHA-256 box above shows exactly what you would store for the current key, computed with the browser's crypto.subtle.digest.

Put a unique index on the hash column and look keys up by hash. For the admin UI, also store something harmless to display, like the prefix and the last four characters, so people can tell their keys apart.

About timing attacks: when you find the row by the hash itself, the comparison is on the SHA-256 of a secret the attacker does not know, so how long it takes tells them nothing useful. If your design finds the row some other way (a key ID, an account) and then compares, compare with a constant-time function: hmac.compare_digest in Python, crypto.timingSafeEqual in Node. The examples below do it either way, because it costs nothing.

Show it once

If you only store the hash, you cannot show the key again, and that is a feature. Display the new key once, tell the user to copy it into their secret manager, and offer a new key instead of a reveal button. Stripe works this way for keys you create in live mode: it shows the key one time and cannot show it again.

Scopes

A key that can do everything is a key whose leak costs everything. Let users create keys that can only read, or only write to one resource, or only work in one project. Store the scopes next to the hash and check them on every request. Stripe now recommends its restricted keys over unrestricted secret keys for exactly this reason, and I have more to say about it in least privilege in the AI age.

Rotation and revocation

Rate limiting

Limit per key, so one broken client cannot starve everyone else, and return HTTP 429 with a Retry-After header so well-behaved clients back off. Separately, rate limit failed authentication attempts per IP address. With 128 or more random bits, nobody is going to guess a key, but a flood of bad keys is still load on your database, which is one more reason to reject bad checksums first.

Keep keys out of client code and URLs

Server-side generation and verification

These produce and check exactly the format this page generates (prefix, 43 base62 characters for 256 bits, 6 character CRC32 checksum). The alphabet order matters for the checksum encoding, so use the same one everywhere.

Python

import hashlib
import hmac
import secrets
import zlib

B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
PREFIX = "sk_live_"
BODY_LEN = 43  # 256 bits

def base62_fixed(n, width=6):
    out = ""
    for _ in range(width):
        n, r = divmod(n, 62)
        out = B62[r] + out
    return out

def hash_key(key):
    return hashlib.sha256(key.encode()).hexdigest()

def new_api_key():
    body = "".join(secrets.choice(B62) for _ in range(BODY_LEN))
    key = PREFIX + body + base62_fixed(zlib.crc32(body.encode()))
    return key, hash_key(key)  # show key once, store only the hash

def checksum_ok(key):
    if not key.startswith(PREFIX) or len(key) != len(PREFIX) + BODY_LEN + 6:
        return False
    body, check = key[len(PREFIX):-6], key[-6:]
    return base62_fixed(zlib.crc32(body.encode())) == check

def authenticate(presented, db):
    if not checksum_ok(presented):
        return None  # typo or junk: no database query
    digest = hash_key(presented)
    row = db.get_api_key_by_hash(digest)  # unique index on key_hash
    if row is None or not hmac.compare_digest(row.key_hash, digest):
        return None
    if row.revoked_at is not None:
        return None
    return row  # now check row.scopes against the request

Node.js

const crypto = require('node:crypto');
const zlib = require('node:zlib'); // zlib.crc32 needs Node 22.2+ or 20.15+

const B62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const PREFIX = 'sk_live_';
const BODY_LEN = 43; // 256 bits

function base62Fixed(n, width = 6) {
  let out = '';
  for (let i = 0; i < width; i += 1) {
    out = B62[n % 62] + out;
    n = Math.floor(n / 62);
  }
  return out;
}

function hashKey(key) {
  return crypto.createHash('sha256').update(key).digest('hex');
}

function newApiKey() {
  let body = '';
  for (let i = 0; i < BODY_LEN; i += 1) body += B62[crypto.randomInt(62)];
  const key = PREFIX + body + base62Fixed(zlib.crc32(body));
  return { key, hash: hashKey(key) }; // show key once, store only the hash
}

function checksumOk(key) {
  if (!key.startsWith(PREFIX) || key.length !== PREFIX.length + BODY_LEN + 6) return false;
  const body = key.slice(PREFIX.length, -6);
  return base62Fixed(zlib.crc32(body)) === key.slice(-6);
}

async function authenticate(presented, db) {
  if (!checksumOk(presented)) return null; // typo or junk: no database query
  const digest = hashKey(presented);
  const row = await db.getApiKeyByHash(digest); // unique index on key_hash
  if (!row) return null;
  const a = Buffer.from(row.keyHash, 'hex');
  const b = Buffer.from(digest, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
  if (row.revokedAt) return null;
  return row; // now check row.scopes against the request
}

secrets.choice in Python and crypto.randomInt in Node both pick without modulo bias. timingSafeEqual throws if the buffers differ in length, hence the length check in front of it.

Quick one-liners

When you just need a key for a config file and do not care about the format:

# 256 bits as hex
openssl rand -hex 32

# 256 bits as base64url (Python)
python3 -c "import secrets; print(secrets.token_urlsafe(32))"

# 43 base62 characters (Python)
python3 -c "import secrets, string; a = string.ascii_letters + string.digits; print(''.join(secrets.choice(a) for _ in range(43)))"

# 256 bits as base64url (Node)
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

If the key is for signing tokens rather than identifying a client, you want the JWT secret generator instead. For framework secrets and raw key material, see the secret key generator and the encryption key generator. More opinions in my security best practices.

Frequently asked questions

How long should an API key be?

What matters is entropy, not character count. 128 random bits is already far beyond anything an attacker can guess over the network, and 256 bits costs you a few extra characters. In base62 that is 22 characters for 128 bits and 43 characters for 256 bits, plus whatever prefix and checksum you add.

Why does an API key need a prefix like sk_live_?

A prefix tells a human (and your logs, and your support team) what kind of key it is and which environment it belongs to without a database lookup. It also gives secret scanners a pattern to match, so a key pasted into a public repository can be spotted and revoked. GitHub reported that adding prefixes to its tokens was expected to bring secret scanning false positives down to about 0.5 percent.

What is the checksum at the end of the key for?

It lets you reject a mistyped or truncated key without touching the database, and it lets a secret scanner confirm that a string really is one of your keys before raising an alarm. This page uses a CRC32 of the random part, encoded as 6 base62 characters, which is the same idea GitHub uses for its tokens. A checksum is error detection, not security: anyone can compute it.

Should I hash API keys with bcrypt or Argon2?

No need. Slow password hashes exist because human passwords have little entropy and can be guessed. A key with 128 or more random bits cannot be guessed, even against a fast hash, so SHA-256 is the usual choice. It also keeps authentication cheap on every request, which a deliberately slow hash would not.

Can I put an API key in my frontend JavaScript or a mobile app?

Not a secret one. Anything shipped to a browser or an app can be extracted by the user. If a client really needs a key, make it a publishable key that only allows harmless actions (Stripe's pk_ keys are the familiar example) and keep secret keys on your servers.

Why not send the API key as a query parameter?

URLs end up in web server access logs, proxy logs, browser history, analytics tools and Referer headers. Send keys in a header instead, usually Authorization: Bearer followed by the key, and make sure your logging does not record that header.

Is the key generated on this page sent anywhere?

No. The random part comes from your browser's crypto.getRandomValues, and the checksum and SHA-256 hash are computed on the page. Nothing is sent to my server. For real production keys, generate them in your own backend with the Python or Node code on this page, since your server has to store the hash anyway.

Related tools: JWT secret generator, secret key generator, encryption key generator, UUID generator, and the strong random password generator.

-Sethers

Newsletter

One email, once a week.

Notes on databases, systems, and the occasional strong opinion about Python. No spam, unsubscribe anytime.

More to read

All tools →

My Password Generators Had 53 Bits of Entropy

Sep 21, 2026

Bcrypt Generator: Hash and Verify Passwords in Your Browser

Sep 17, 2026

Django Secret Key Generator (get_random_secret_key)

Sep 17, 2026