Skip to content
gohacks.dev
APIs

API Keys in Go and How to Generate Hash and Rotate Them

Generate, store and rotate API keys in Go: a prefixed format with a checksum, SHA-256 storage, throttled last-used, overlap rotation and 85 ns rejection.

13 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

An API key is the simplest credential an API can issue: a long random string, shown once, sent in a header. It is also the credential most often handled badly. Keys end up in plain text in a database column, in a log line, in a git history, in a customer's support ticket, and the code that checks them is a string comparison with ==. None of that is hard to avoid, and the fixes fit in a couple of hundred lines.

Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)

This article builds a key manager in Go with SQLite: a key format designed to be useful, storage that cannot leak a working key, verification that is cheap to reject and safe to compare, last-used tracking that does not turn reads into writes, and rotation with an overlap. It is the deeper version of the API-key section in authentication for Go APIs, and it connects to rate limiting per key and the security checklist. Everything ran on Go 1.25.6 with modernc.org/sqlite v1.50.0.

The format is a design decision

A bare random string works, and a structured one works better. This one is ghk_live_<id>_<secret>_<check>.

  • A prefix (ghk_live_) names the product and the environment. Secret scanners in code hosts and CI can be configured to look for it, so a key committed by accident is found in minutes, and a human reading a log line knows what the string is. Separating live from test keeps one from being used where the other belongs.
  • An id (8 characters) is the lookup key. It is not secret. It lets the server find the record without scanning, and lets a customer say "revoke key sapsc5b6" without pasting a secret into a ticket.
  • A secret (32 characters of base32, 160 bits) is the part that must be unguessable. It comes from crypto/rand, never from math/rand, a timestamp or a UUID derived from one.
  • A checksum (6 characters) is a CRC of everything before it. It is not security: anyone can compute it. Its use is to let the server tell "this is not a key at all" from "this looks like a key but is wrong" without touching storage.
keys.goGo
package main

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"database/sql"
	"encoding/base32"
	"encoding/binary"
	"errors"
	"fmt"
	"hash/crc32"
	"strings"
	"sync/atomic"
	"time"

	_ "modernc.org/sqlite"
)

// Format: ghk_<env>_<id>_<secret>_<check>. The prefix names the product so secret scanners can find a leak, the id is
// the lookup key, the secret is 160 random bits, and the check is a CRC of everything before it so typos and
// garbage are rejected without touching the database.
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)

var (
	ErrMalformed = errors.New("malformed key")
	ErrInvalid   = errors.New("invalid key")
	ErrRevoked   = errors.New("key revoked")
	ErrExpired   = errors.New("key expired")
)

type Keys struct {
	db  *sql.DB
	now func() time.Time
}

var dbs atomic.Int64

func Open(now func() time.Time) *Keys {
	db, err := sql.Open("sqlite", fmt.Sprintf("file:keys%d?mode=memory&cache=shared", dbs.Add(1)))
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	_, err = db.Exec(`CREATE TABLE api_keys (
		id TEXT PRIMARY KEY, owner TEXT NOT NULL, hash BLOB NOT NULL, scopes TEXT NOT NULL,
		created_at INTEGER NOT NULL, expires_at INTEGER, revoked_at INTEGER, last_used_at INTEGER)`)
	if err != nil {
		panic(err)
	}
	return &Keys{db, now}
}

func randomText(n int) string {
	b := make([]byte, n)
	rand.Read(b)
	return strings.ToLower(b32.EncodeToString(b))
}

func checksum(body string) string {
	var b [4]byte
	binary.BigEndian.PutUint32(b[:], crc32.ChecksumIEEE([]byte(body)))
	return strings.ToLower(b32.EncodeToString(b[:]))[:6]
}

// Create returns the full key once. Only the id and a SHA-256 of the secret are stored.
func (k *Keys) Create(owner string, scopes []string, ttl time.Duration) (string, error) {
	id, secret := randomText(5), randomText(20) // 8 and 32 characters
	body := "ghk_live_" + id + "_" + secret
	var expires any
	if ttl > 0 {
		expires = k.now().Add(ttl).Unix()
	}
	h := sha256.Sum256([]byte(secret))
	_, err := k.db.Exec(`INSERT INTO api_keys (id, owner, hash, scopes, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
		id, owner, h[:], strings.Join(scopes, " "), k.now().Unix(), expires)
	return body + "_" + checksum(body), err
}

type Key struct {
	ID, Owner string
	Scopes    []string
}

// Parse validates the shape and the checksum. It costs nothing and touches no storage, so garbage never reaches the database.
func Parse(key string) (id, secret string, err error) {
	i := strings.LastIndexByte(key, '_')
	if i < 0 || checksum(key[:i]) != key[i+1:] {
		return "", "", ErrMalformed
	}
	parts := strings.Split(key[:i], "_")
	if len(parts) != 4 || parts[0] != "ghk" || len(parts[2]) != 8 || len(parts[3]) != 32 {
		return "", "", ErrMalformed
	}
	return parts[2], parts[3], nil
}

func (k *Keys) Verify(key string) (Key, error) {
	id, secret, err := Parse(key)
	if err != nil {
		return Key{}, err
	}
	var (
		owner, scopes              string
		hash                       []byte
		expires, revoked, lastUsed sql.NullInt64
	)
	err = k.db.QueryRow(`SELECT owner, hash, scopes, expires_at, revoked_at, last_used_at FROM api_keys WHERE id = ?`, id).
		Scan(&owner, &hash, &scopes, &expires, &revoked, &lastUsed)
	got := sha256.Sum256([]byte(secret))
	if errors.Is(err, sql.ErrNoRows) {
		hash = make([]byte, 32) // compare anyway, so a missing id and a wrong secret cost the same
	} else if err != nil {
		return Key{}, err
	}
	if subtle.ConstantTimeCompare(got[:], hash) != 1 || errors.Is(err, sql.ErrNoRows) {
		return Key{}, ErrInvalid
	}
	now := k.now().Unix()
	switch {
	case revoked.Valid:
		return Key{}, ErrRevoked
	case expires.Valid && now >= expires.Int64:
		return Key{}, ErrExpired
	}
	// Record use at most once a minute: a write per request would make every read a write.
	if !lastUsed.Valid || now-lastUsed.Int64 >= 60 {
		k.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, now, id)
	}
	return Key{id, owner, strings.Fields(scopes)}, nil
}

func (k *Keys) Revoke(id string) error {
	_, err := k.db.Exec(`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, k.now().Unix(), id)
	return err
}

// Rotate issues a replacement with the same owner and scopes, and lets the old key live for the grace period.
func (k *Keys) Rotate(oldID string, grace, ttl time.Duration) (string, error) {
	var owner, scopes string
	if err := k.db.QueryRow(`SELECT owner, scopes FROM api_keys WHERE id = ? AND revoked_at IS NULL`, oldID).Scan(&owner, &scopes); err != nil {
		return "", err
	}
	fresh, err := k.Create(owner, strings.Fields(scopes), ttl)
	if err != nil {
		return "", err
	}
	_, err = k.db.Exec(`UPDATE api_keys SET expires_at = ? WHERE id = ?`, k.now().Add(grace).Unix(), oldID)
	return fresh, err
}

// LastUsed reports when a key was last used, for a dashboard.
func (k *Keys) LastUsed(id string) (time.Time, bool) {
	var t sql.NullInt64
	k.db.QueryRow(`SELECT last_used_at FROM api_keys WHERE id = ?`, id).Scan(&t)
	return time.Unix(t.Int64, 0).UTC(), t.Valid
}

Creating and storing

Create generates the id and secret, builds the key and inserts a row with the id and a SHA-256 of the secret, not the secret. The caller gets the key back once; nothing can retrieve it again, which is the same promise cloud providers make and for the same reason. A user who loses a key creates a new one.

Why SHA-256 and not bcrypt? A password hash is slow on purpose because passwords are guessable: an attacker with the hash tries millions of candidates. A 160-bit random secret cannot be guessed, however fast the hash, so the slow hash adds latency to every API request and no security. The benchmark makes the point: hashing the secret with SHA-256 took 52 ns, and bcrypt at cost 10 took 64.3 ms, about a million and a quarter times longer, paid on every request. Use a fast hash for high-entropy secrets and a slow one for passwords.

The row also holds the owner, the scopes (a space-separated string here), creation, expiry, revocation and last-use times. Expiry is optional per key, set from a TTL at creation. Keys that never expire are convenient and are a decision to make deliberately; a default of a year with renewal is easier to defend.

Verifying

Verify does five things in order, and the order matters.

  1. Parse checks the shape and the checksum. Anything that is not a well-formed key, including a typo, is rejected here, before the database is involved. This cheap check is a defence against floods of junk: a bad key cost 85 ns to reject, against 6.9 µs for a full verification.
  2. It looks up the record by id. One indexed read.
  3. It hashes the presented secret and compares in constant time with subtle.ConstantTimeCompare. If the id was unknown, it compares against zeros anyway, so unknown ids and wrong secrets take the same time and give the same error (timing in authentication).
  4. It checks revocation and expiry, in that order, and returns distinct errors the middleware can map to messages. Both become a 401 for the client; the distinction is for your logs.
  5. It records use, at most once a minute. Updating last_used_at on every request would turn every read into a write and make the keys table a hot spot. Throttling keeps the dashboard accurate to a minute, which is all a person looking at "last used" needs.

What it did

main.goGo
package main

import (
	"fmt"
	"strings"
	"time"
)

func main() {
	clock := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
	k := Open(func() time.Time { return clock })

	key, _ := k.Create("billing-service", []string{"invoices:read", "invoices:write"}, 90*24*time.Hour)
	id, _, _ := Parse(key)
	fmt.Println("key shown once:", key)
	fmt.Printf("id=%s  length=%d\n\n", id, len(key))

	try := func(label, key string) {
		got, err := k.Verify(key)
		if err != nil {
			fmt.Printf("%-34s rejected: %v\n", label, err)
			return
		}
		fmt.Printf("%-34s ok: owner=%s scopes=%v\n", label, got.Owner, got.Scopes)
	}
	try("the real key", key)
	try("one character changed (typo)", typo(key))
	try("right shape, wrong secret", fixSum("ghk_live_"+id+"_"+strings.Repeat("a", 32)))
	try("right shape, unknown id", fixSum("ghk_live_aaaaaaaa_"+strings.Repeat("a", 32)))
	try("not a key at all", "hunter2")

	if t, ok := k.LastUsed(id); ok {
		fmt.Println("\nlast used:", t.Format(time.RFC3339))
	}
	clock = clock.Add(30 * time.Second)
	k.Verify(key)
	t, _ := k.LastUsed(id)
	fmt.Println("30 s later, used again, last_used still:", t.Format(time.RFC3339), "(writes are throttled)")
	clock = clock.Add(2 * time.Minute)
	k.Verify(key)
	t, _ = k.LastUsed(id)
	fmt.Println("2m30s later:", t.Format(time.RFC3339))

	fmt.Println("\n== rotation with a 7 day overlap")
	fresh, _ := k.Rotate(id, 7*24*time.Hour, 90*24*time.Hour)
	try("old key during the overlap", key)
	try("new key", fresh)
	clock = clock.Add(8 * 24 * time.Hour)
	try("old key after the overlap", key)
	try("new key after the overlap", fresh)

	fmt.Println("\n== revocation is immediate")
	newID, _, _ := Parse(fresh)
	k.Revoke(newID)
	try("revoked key", fresh)
}

// typo changes one character, always to a different one.
func typo(key string) string {
	c := byte('x')
	if key[20] == 'x' {
		c = 'y'
	}
	return key[:20] + string(c) + key[21:]
}

func fixSum(body string) string { return body + "_" + checksum(body) }
Text
key shown once: ghk_live_lle2plim_dixaceuzmnoqrh2hliqofdo5iplsdezx_mybqzf
id=lle2plim  length=57

the real key                       ok: owner=billing-service scopes=[invoices:read invoices:write]
one character changed (typo)       rejected: malformed key
right shape, wrong secret          rejected: invalid key
right shape, unknown id            rejected: invalid key
not a key at all                   rejected: malformed key

last used: 2026-06-01T09:00:00Z
30 s later, used again, last_used still: 2026-06-01T09:00:00Z (writes are throttled)
2m30s later: 2026-06-01T09:02:30Z

== rotation with a 7 day overlap
old key during the overlap         ok: owner=billing-service scopes=[invoices:read invoices:write]
new key                            ok: owner=billing-service scopes=[invoices:read invoices:write]
old key after the overlap          rejected: key expired
new key after the overlap          ok: owner=billing-service scopes=[invoices:read invoices:write]

== revocation is immediate
revoked key                        rejected: key revoked

The key is 57 characters. The real key verifies and returns its owner and scopes. A key with one character changed fails at the checksum with malformed key. A key with the right shape and a wrong secret, and one with an unknown id, both come back invalid key; the caller cannot tell which was wrong. A string like hunter2 is malformed.

The last-used lines show the throttle: after a verification 30 seconds later the stored time is unchanged, and after two and a half minutes it moves.

Rotation is the most useful part. Rotate creates a new key for the same owner and scopes and sets the old key's expiry to seven days from now. During that window both keys work, so the customer can deploy the new one at their own pace without an outage. After the window the old one is key expired and the new one still works. If a key is compromised, Revoke sets revoked_at and the key fails at once with key revoked; no overlap, no waiting.

Tests

keys_test.goGo
package main

import (
	"strings"
	"testing"
	"time"
)

func newKeys() (*Keys, *time.Time) {
	clock := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
	return Open(func() time.Time { return clock }), &clock
}

func TestCreatedKeysVerifyAndCarryScopes(t *testing.T) {
	k, _ := newKeys()
	key, _ := k.Create("svc", []string{"a", "b"}, 0)
	got, err := k.Verify(key)
	if err != nil || got.Owner != "svc" || strings.Join(got.Scopes, ",") != "a,b" {
		t.Fatalf("%+v %v", got, err)
	}
}

func TestDatabaseNeverSeesTheSecret(t *testing.T) {
	k, _ := newKeys()
	key, _ := k.Create("svc", nil, 0)
	_, secret, _ := Parse(key)
	var n int
	k.db.QueryRow(`SELECT count(*) FROM api_keys WHERE instr(hash, ?) > 0 OR instr(id, ?) > 0 OR instr(owner, ?) > 0`, secret, secret, secret).Scan(&n)
	if n != 0 {
		t.Fatal("the secret appears in a stored column")
	}
}

func TestEveryByteOfTheKeyMatters(t *testing.T) {
	k, _ := newKeys()
	key, _ := k.Create("svc", nil, 0)
	for i := 0; i < len(key); i++ {
		c := key[i]
		var r byte = 'a'
		if c == 'a' {
			r = 'b'
		}
		if _, err := k.Verify(key[:i] + string(r) + key[i+1:]); err == nil {
			t.Fatalf("changing byte %d was accepted", i)
		}
	}
}

func TestExpiryRevocationAndRotation(t *testing.T) {
	k, clock := newKeys()
	short, _ := k.Create("svc", nil, time.Hour)
	*clock = clock.Add(2 * time.Hour)
	if _, err := k.Verify(short); err != ErrExpired {
		t.Fatalf("got %v", err)
	}
	long, _ := k.Create("svc", nil, 0)
	id, _, _ := Parse(long)
	k.Revoke(id)
	if _, err := k.Verify(long); err != ErrRevoked {
		t.Fatalf("got %v", err)
	}
}
Text
=== RUN   TestCreatedKeysVerifyAndCarryScopes
--- PASS: TestCreatedKeysVerifyAndCarryScopes (0.01s)
=== RUN   TestDatabaseNeverSeesTheSecret
--- PASS: TestDatabaseNeverSeesTheSecret (0.00s)
=== RUN   TestEveryByteOfTheKeyMatters
--- PASS: TestEveryByteOfTheKeyMatters (0.00s)
=== RUN   TestExpiryRevocationAndRotation
--- PASS: TestExpiryRevocationAndRotation (0.00s)
PASS
ok  	example.test/lab	1.366s

Four tests carry the guarantees. Created keys verify and carry their scopes. A query over the table confirms that the secret does not appear in any stored column. The third test changes each position of a valid key, one at a time, and requires every change to be rejected, which catches a parser that ignores part of its input. The fourth covers expiry and revocation with an injected clock, so no test sleeps.

What it costs

bench_test.goGo
package main

import (
	"crypto/sha256"
	"testing"

	"golang.org/x/crypto/bcrypt"
)

func BenchmarkVerifyValidKey(b *testing.B) {
	k, _ := newKeys()
	key, _ := k.Create("svc", []string{"a"}, 0)
	b.ReportAllocs()
	for b.Loop() {
		if _, err := k.Verify(key); err != nil {
			b.Fatal(err)
		}
	}
}

func BenchmarkRejectBadChecksum(b *testing.B) {
	k, _ := newKeys()
	key, _ := k.Create("svc", nil, 0)
	bad := typo(key)
	b.ReportAllocs()
	for b.Loop() {
		k.Verify(bad)
	}
}

func BenchmarkSHA256OfSecret(b *testing.B) {
	secret := []byte("0123456789abcdef0123456789abcdef")
	for b.Loop() {
		sha256.Sum256(secret)
	}
}

func BenchmarkBcryptCost10OfSecret(b *testing.B) {
	h, _ := bcrypt.GenerateFromPassword([]byte("0123456789abcdef0123456789abcdef"), 10)
	b.ResetTimer()
	for b.Loop() {
		bcrypt.CompareHashAndPassword(h, []byte("0123456789abcdef0123456789abcdef"))
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkVerifyValidKey-10          	  149348	      6930 ns/op	    1072 B/op	      39 allocs/op
BenchmarkRejectBadChecksum-10       	13903820	        85.17 ns/op	      80 B/op	       3 allocs/op
BenchmarkSHA256OfSecret-10          	22904896	        52.38 ns/op	       0 B/op	       0 allocs/op
BenchmarkBcryptCost10OfSecret-10    	      18	  64293991 ns/op	    5236 B/op	      11 allocs/op
PASS
ok  	example.test/lab	4.944s

On an Apple M1 Max against an in-memory database, a valid verification took 6.9 µs and 39 allocations, a malformed key took 85 ns and 3, SHA-256 of the secret 52 ns, and the bcrypt comparison 64.3 ms. A real database across a network will make the lookup slower than 6.9 µs, which is the case for a small cache of recently verified keys. If you add one, key it by the SHA-256 of the whole key, give it a short TTL, and make revocation purge it, or a revoked key will keep working for the length of the cache.

Operating keys

Show them once, and say so. The creation response is the only time the key exists in the clear. State it in the UI and the API reference.

Scope narrowly. A key for a reporting job should not write. See the scope discussion in authentication.

Never log them. Redact X-API-Key and Authorization in access logs and error reports (redacting logs). The prefix helps your own scanners find leaks in log storage.

Send them in headers, not URLs. Query strings are logged by every proxy on the path.

Rate-limit by key. The key id identifies the client without revealing the secret, so it is a natural bucket for limits and usage metering (usage metering).

Make leaks cheap to handle. A revoke button, a rotation endpoint and a list of keys with last-used times mean a leaked key is a five-minute problem. Registering your prefix with code hosts' secret scanning programs, where offered, can notify you when a key is pushed to a public repository.

Prefer something stronger across trust boundaries. Keys are fine between a customer's servers and yours. For a partner integration with sensitive actions, mutual TLS or OAuth client credentials give stronger identity and finer control (OAuth 2 basics).

The standard library pieces used are crypto/rand, crypto/subtle and hash/crc32.

Also inSecurity
Taggedsecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.