Designing API Authentication for B2B SaaS: API Keys, OAuth Client Credentials, and Scoped Access Tokens
A production guide to B2B API authentication covering API key generation and hashing, OAuth 2.0 client credentials flow, scoped access tokens, rate limiting per key, audit logging, webhook signature verification, key rotation, and key compromise response in TypeScript.
Most B2B SaaS products eventually reach the point where a customer asks: “Can we integrate with this programmatically?” The answer is almost always yes, but the authentication model you pick for that integration shapes everything downstream: your security posture, your developer experience, your audit trail, and how painful key rotation becomes when something goes wrong.
There are three main approaches worth understanding. API keys for their simplicity. OAuth 2.0 client credentials for their security and standardization. Scoped access tokens for fine-grained permission control. Each has a natural fit depending on integration tier, customer technical sophistication, and how much access surface you are comfortable exposing.
This article covers each pattern with TypeScript implementations, traces the real tradeoffs between them, and goes into the production concerns that rarely appear in documentation: key hashing, rate limiting per key, audit logging, webhook signature verification, key compromise response, and deprecation workflows.
The Core Problem
Machine-to-machine authentication in B2B SaaS is different from user authentication in two important ways.
First, there is no interactive login. The client system needs to authenticate unattended, on every API call. That means no browser redirect, no MFA prompt, no session cookie. The credential either travels in the request or is exchanged for a short-lived token before the call.
Second, the credential often has broad or long-lived access. A user session expires. An API key or client secret may be valid for months or years. If it leaks, the blast radius is larger and the window of exposure is longer.
These two constraints push you toward different design decisions than user auth:
- Credentials must be easy to pass in requests (headers, not cookies)
- Secrets must be stored hashed, not encrypted, since you never need to recover the plaintext
- Every request carrying a credential must be logged, because you cannot rely on browser sessions for audit trails
- Rotation must be possible without breaking the integration
Approach 1: API Keys
API keys are the lowest-friction option. A key is an opaque, high-entropy string. The client puts it in an Authorization or X-API-Key header. You look it up, confirm it is valid, and attach the associated account context to the request.
They work well for:
- Partners or customers who want simple integration without OAuth infrastructure
- Internal service-to-service communication within a trusted boundary
- Early-stage products where OAuth flows are not yet worth the implementation cost
Key Generation
Generate keys with sufficient entropy. 32 bytes of cryptographic randomness, base64url-encoded, gives you 256 bits of entropy and roughly 43 printable characters. That is more than enough.
import { randomBytes } from 'crypto'
function generateApiKey(): string {
// Prefix for easy identification in logs, secret scanners, git history scanning
const prefix = 'lbs'
const secret = randomBytes(32).toString('base64url')
return `${prefix}_${secret}`
}
The prefix lbs_ (or whatever your product abbreviation is) makes the key identifiable in GitHub secret scanning, grep output, and support tickets. GitHub’s secret scanning program lets you register a prefix pattern so keys accidentally committed to repos trigger automatic alerts.
Hashing and Storage
Never store the plaintext key. Once you hand the key to the customer, you should not be able to recover it. Store a bcrypt or SHA-256 hash instead.
import { createHash } from 'crypto'
import { randomBytes } from 'crypto'
interface ApiKeyRecord {
id: string
tenantId: string
name: string
keyHash: string // SHA-256 hash, stored in DB
prefix: string // First 8 chars for display: "lbs_xK3m..."
scopes: string[]
createdAt: Date
lastUsedAt: Date | null
expiresAt: Date | null
revokedAt: Date | null
}
function hashApiKey(key: string): string {
return createHash('sha256').update(key).digest('hex')
}
async function createApiKey(
tenantId: string,
name: string,
scopes: string[]
): Promise<{ record: ApiKeyRecord; plaintext: string }> {
const plaintext = generateApiKey()
const record: ApiKeyRecord = {
id: crypto.randomUUID(),
tenantId,
name,
keyHash: hashApiKey(plaintext),
prefix: plaintext.slice(0, 12),
scopes,
createdAt: new Date(),
lastUsedAt: null,
expiresAt: null,
revokedAt: null,
}
// INSERT record into your DB here
// Return plaintext ONCE, immediately after creation — never again
return { record, plaintext }
}
SHA-256 is appropriate here because API keys are already high-entropy secrets. bcrypt is designed for low-entropy passwords. For 256-bit random secrets, SHA-256 is fast and sufficient — the key is not guessable from the hash even without a salt, because the key space is too large for brute force.
Store the prefix field (first 8-12 characters) for display purposes. When a customer asks “which key is being used?”, you can show them lbs_xK3mY9... without exposing the full key.
Validation Middleware
import { createHash } from 'crypto'
interface AuthContext {
tenantId: string
keyId: string
scopes: string[]
}
async function validateApiKey(
rawKey: string,
db: DatabaseClient
): Promise<AuthContext | null> {
const keyHash = hashApiKey(rawKey)
const record = await db.query<ApiKeyRecord>(
`SELECT * FROM api_keys
WHERE key_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())`,
[keyHash]
)
if (!record) return null
// Fire-and-forget last-used update; don't block the request on it
db.query(
`UPDATE api_keys SET last_used_at = NOW() WHERE id = $1`,
[record.id]
).catch(console.error)
return {
tenantId: record.tenantId,
keyId: record.id,
scopes: record.scopes,
}
}
The lookup is O(1) on the hash column. Put a unique index on key_hash. The last_used_at update is fire-and-forget because blocking the request on a write for every API call adds latency and creates a write hotspot.
Rate Limiting Per Key
Rate limit on the key, not on the tenant. Two different integrations from the same customer should not share a rate limit bucket, because one runaway script should not take down the other.
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
interface RateLimitResult {
allowed: boolean
remaining: number
resetAt: number
}
async function checkRateLimit(
keyId: string,
limitPerMinute: number
): Promise<RateLimitResult> {
const windowKey = `ratelimit:key:${keyId}:${Math.floor(Date.now() / 60_000)}`
const pipeline = redis.multi()
pipeline.incr(windowKey)
pipeline.expire(windowKey, 120) // 2-minute TTL for cleanup
const [count] = await pipeline.exec() as [number, number]
const resetAt = (Math.floor(Date.now() / 60_000) + 1) * 60_000
return {
allowed: count <= limitPerMinute,
remaining: Math.max(0, limitPerMinute - count),
resetAt,
}
}
Return X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers on every response, not just when the limit is hit. Integrators use those headers to build adaptive request pacing without trial and error.
Approach 2: OAuth 2.0 Client Credentials
Client credentials is the OAuth flow designed for machine-to-machine authentication. There is no user in the loop. The client exchanges a client_id and client_secret for a short-lived access token, then uses that token on subsequent API calls until it expires.
It fits well for:
- Enterprise customers with compliance requirements that mandate short-lived credentials
- Integrations where you want fine-grained scope consent at token issuance
- Partners who already have OAuth infrastructure and expect it
The overhead versus API keys is real: you are running a token endpoint, issuing JWTs or opaque tokens, and managing token expiry on the client side. For a small integration, this complexity can frustrate developers. For an enterprise security team, it is expected.
Token Issuance
import { SignJWT } from 'jose'
import { createHash, randomBytes } from 'crypto'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const TOKEN_TTL_SECONDS = 3600 // 1 hour
interface TokenResponse {
access_token: string
token_type: 'Bearer'
expires_in: number
scope: string
}
async function issueClientCredentialsToken(
clientId: string,
clientSecret: string,
requestedScopes: string[],
db: DatabaseClient
): Promise<TokenResponse | null> {
// Look up client
const client = await db.queryOne(
`SELECT * FROM oauth_clients WHERE client_id = $1 AND revoked_at IS NULL`,
[clientId]
)
if (!client) return null
// Verify secret
const secretHash = createHash('sha256').update(clientSecret).digest('hex')
if (secretHash !== client.secretHash) return null
// Intersect requested scopes with allowed scopes
const allowedScopes = new Set<string>(client.allowedScopes)
const grantedScopes = requestedScopes.filter(s => allowedScopes.has(s))
if (grantedScopes.length === 0) return null
const jti = randomBytes(16).toString('hex') // unique token ID for revocation
const accessToken = await new SignJWT({
sub: clientId,
tenantId: client.tenantId,
scopes: grantedScopes,
jti,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${TOKEN_TTL_SECONDS}s`)
.sign(JWT_SECRET)
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: TOKEN_TTL_SECONDS,
scope: grantedScopes.join(' '),
}
}
The jti (JWT ID) field enables token revocation before expiry. Store it in a Redis set or a revoked_tokens table, and check it on each request. Without jti, you cannot revoke a token until it expires naturally.
Token Validation Middleware
import { jwtVerify } from 'jose'
async function validateBearerToken(
authHeader: string | undefined,
db: DatabaseClient,
redis: RedisClient
): Promise<AuthContext | null> {
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7)
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
const { sub: clientId, tenantId, scopes, jti } = payload as {
sub: string
tenantId: string
scopes: string[]
jti: string
}
// Check revocation list
const isRevoked = await redis.get(`revoked:token:${jti}`)
if (isRevoked) return null
return { tenantId, keyId: clientId, scopes }
} catch {
return null
}
}
Approach 3: Scoped Access Tokens
Both API keys and client credentials tokens can carry scopes, but “scoped access tokens” as a pattern refers to a deliberate permission model baked into your authorization layer, not just scope strings on a JWT.
The permission hierarchy for a B2B API typically looks like:
read:invoices— read-only access to invoiceswrite:invoices— create and update invoicesadmin:billing— full billing administration including deletion and refundswebhooks:manage— register and delete webhook endpoints
Scope enforcement must happen at the handler level, not just at the auth middleware. Middleware confirms the request is authenticated. Scope checking confirms the credential is authorized for the specific operation being requested.
function requireScopes(...required: string[]) {
return (ctx: Context, next: NextFunction) => {
const auth: AuthContext = ctx.get('auth')
const grantedScopes = new Set(auth.scopes)
const missing = required.filter(s => !grantedScopes.has(s))
if (missing.length > 0) {
return ctx.json(
{
error: 'insufficient_scope',
required: required,
granted: auth.scopes,
},
403
)
}
return next()
}
}
// Usage in route definitions
app.get('/invoices', requireScopes('read:invoices'), listInvoices)
app.post('/invoices', requireScopes('write:invoices'), createInvoice)
app.delete('/invoices/:id', requireScopes('admin:billing'), deleteInvoice)
The error response includes both required and granted. This is critical for developer experience. When an integration fails with a 403, the developer needs to know exactly which scope they are missing, not just that access was denied.
Tradeoffs Compared
| Factor | API Keys | Client Credentials | Scoped Tokens |
|---|---|---|---|
| Implementation cost | Low | High | Medium (layered on top) |
| Short-lived credentials | No (manual rotation) | Yes (1h default) | Depends on underlying mechanism |
| Revocation speed | Immediate (delete from DB) | Requires jti denylist | Same as above |
| Developer experience | Excellent | Good (familiar OAuth) | Depends on docs quality |
| Compliance fit | Basic | Strong (SOC 2, ISO 27001) | Strong |
| Token introspection | Requires DB lookup | Self-contained JWT | Self-contained |
| Scope granularity | Can add scopes to keys | Native | Explicit, queryable |
| Rate limiting | Straightforward | Requires token identity | Same as base mechanism |
For most early-stage B2B SaaS products, API keys with scopes are the right starting point. Add OAuth client credentials when enterprise customers ask for it or when your compliance requirements demand short-lived credentials.
Audit Logging
Every authenticated request must emit a structured log event. Not to your application logger, which may be sampled or filtered: to a dedicated audit sink.
interface AuditEvent {
timestamp: string
tenantId: string
credentialId: string
credentialType: 'api_key' | 'oauth_token'
method: string
path: string
statusCode: number
scopes: string[]
ipAddress: string
userAgent: string
requestId: string
durationMs: number
}
async function emitAuditEvent(event: AuditEvent): Promise<void> {
// Write to append-only audit table
await db.query(
`INSERT INTO api_audit_log
(timestamp, tenant_id, credential_id, credential_type, method, path,
status_code, scopes, ip_address, user_agent, request_id, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
event.timestamp, event.tenantId, event.credentialId,
event.credentialType, event.method, event.path, event.statusCode,
event.scopes, event.ipAddress, event.userAgent,
event.requestId, event.durationMs,
]
)
}
Keep audit logs for at least 90 days. Many compliance frameworks require 12 months. The audit table should be append-only: no UPDATE, no DELETE, enforced by a Postgres role that only has INSERT and SELECT.
Webhook Signature Verification
If you send webhooks, the receiving system needs a way to verify the payload came from you. The standard approach is HMAC-SHA256 signing.
import { createHmac, timingSafeEqual } from 'crypto'
function signWebhookPayload(
payload: string,
secret: string,
timestamp: number
): string {
const signedContent = `${timestamp}.${payload}`
return createHmac('sha256', secret).update(signedContent).digest('hex')
}
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
toleranceSeconds = 300
): boolean {
const parts = signature.split(',')
const tPart = parts.find(p => p.startsWith('t='))
const vPart = parts.find(p => p.startsWith('v1='))
if (!tPart || !vPart) return false
const timestamp = parseInt(tPart.slice(2), 10)
const receivedSig = vPart.slice(3)
// Reject stale webhooks
const age = Math.abs(Date.now() / 1000 - timestamp)
if (age > toleranceSeconds) return false
const expectedSig = signWebhookPayload(payload, secret, timestamp)
// Constant-time comparison to prevent timing attacks
const expected = Buffer.from(expectedSig, 'hex')
const received = Buffer.from(receivedSig, 'hex')
if (expected.length !== received.length) return false
return timingSafeEqual(expected, received)
}
Include the timestamp in the signed content and validate it on receipt. This prevents replay attacks: a captured webhook payload cannot be re-sent 10 minutes later to trigger the same action twice.
The signature format t=1234567890,v1=abc123... follows the convention Stripe established. Your customers’ developers will recognize it.
Key Rotation Workflow
Key rotation is one of those things that sounds simple and turns out to have sharp edges. The naive approach — revoke old key, issue new key — causes downtime if the customer has not yet deployed the new key.
The correct pattern is a grace period:
- Issue new key (key B) while key A remains active
- Customer deploys key B to their systems
- Customer confirms key B is working in production
- Customer or your UI revokes key A
- Optionally: auto-revoke key A after a grace period (7-30 days)
async function rotateApiKey(
existingKeyId: string,
tenantId: string,
db: DatabaseClient
): Promise<{ newKey: string; newKeyId: string; oldKeyId: string }> {
const existing = await db.queryOne<ApiKeyRecord>(
`SELECT * FROM api_keys WHERE id = $1 AND tenant_id = $2 AND revoked_at IS NULL`,
[existingKeyId, tenantId]
)
if (!existing) throw new Error('Key not found or already revoked')
// Create new key with same scopes
const { record, plaintext } = await createApiKey(
tenantId,
`${existing.name} (rotated)`,
existing.scopes
)
// Schedule old key revocation after grace period (do not revoke immediately)
await db.query(
`UPDATE api_keys SET scheduled_revoke_at = NOW() + INTERVAL '7 days' WHERE id = $1`,
[existingKeyId]
)
return {
newKey: plaintext,
newKeyId: record.id,
oldKeyId: existingKeyId,
}
}
A background job checks scheduled_revoke_at and sets revoked_at when the time comes. Customers see a warning in the key management UI: “Key A will be revoked on [date]. Switch to Key B before then.”
Key Compromise Response
When a key leaks, every minute matters. You need a response path that takes under two minutes from alert to revocation.
async function revokeKeyImmediately(
keyId: string,
reason: string,
revokedBy: string,
db: DatabaseClient,
redis: RedisClient
): Promise<void> {
// Revoke in DB
await db.query(
`UPDATE api_keys
SET revoked_at = NOW(), revocation_reason = $1, revoked_by = $2
WHERE id = $3`,
[reason, revokedBy, keyId]
)
// Push revocation to Redis so in-flight requests fail fast
// without waiting for the DB read to propagate
await redis.setEx(`revoked:key:${keyId}`, 86_400, '1')
// Emit audit event
await emitAuditEvent({
timestamp: new Date().toISOString(),
tenantId: 'system',
credentialId: keyId,
credentialType: 'api_key',
method: 'REVOKE',
path: '/admin/keys/revoke',
statusCode: 200,
scopes: [],
ipAddress: '0.0.0.0',
userAgent: 'system',
requestId: crypto.randomUUID(),
durationMs: 0,
})
}
The Redis write for revocation is the critical part. If your validation path does a DB lookup for every request, revocation propagates when the next request hits the DB. But if you cache key lookups in Redis with a TTL, you need to also write the revocation to Redis directly, or you have a window where compromised keys still work until the cache expires.
Cache key records with a short TTL (60 seconds), and write revocations to Redis immediately. That limits your exposure window without adding a DB round-trip to every request.
Key Management UI
Developers managing integrations need to see:
- All active keys with their names, prefixes, scopes, and last-used timestamps
- Which keys are scheduled for rotation and when the old one will be revoked
- Per-key request volume over the last 7 and 30 days
- The ability to create, name, scope, and revoke keys without filing a support ticket
“Last used” is a surprisingly important field. Customers often have forgotten keys that were created for a one-off integration two years ago. Showing Last used: 547 days ago is usually enough context to prompt cleanup.
Usage analytics per key are useful both for customers and for you. They surface keys that are driving high request volumes (and may need higher rate limits), keys that have not been used in months (candidates for deprecation), and abnormal usage spikes that may indicate a compromised key being used by an attacker.
Deprecation Workflows
At some point you will want to kill an old authentication mechanism: an API version, a key format, a scope name. The workflow is the same as key rotation, but coordinated at scale.
- Announce the deprecation with a date, at least 90 days out
- Add a
DeprecationandSunsetheader to every response from the deprecated API - Send email notifications to tenants with active keys using the deprecated mechanism
- Track which keys have migrated and which have not
- Two weeks before sunset, send a second round of notifications to the remaining non-migrated tenants
- On the sunset date, return 410 Gone with a clear error message pointing to documentation
The Deprecation and Sunset headers are part of the IETF draft that many API clients and monitoring tools understand. Including them costs nothing and gives integrators an automated signal.
Putting It Together
The authentication model for a B2B API is not a one-time decision. It evolves as your customer base matures and your compliance requirements grow.
Start with API keys. They are simple to implement, simple to understand, and simple to revoke. Add scopes from day one, even if the first version only has two (read and write). Add OAuth client credentials when you sign your first enterprise contract or your first SOC 2 audit. Keep both mechanisms running in parallel: different customers have different infrastructure, and forcing a migration creates friction that delays deals.
The production concerns — hashing, rate limiting, audit logging, rotation, compromise response — apply to all three mechanisms. Get those right early. They are much harder to retrofit than the authentication mechanism itself.
The key that matters most in B2B API authentication is not which protocol you use. It is whether the customer’s developers trust that the system is predictable, debuggable, and honest with them when something goes wrong. That trust is built through good error messages, honest rate limit headers, and a key management UI that does not require a support ticket.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.