Secure API Key Generation, Validation, and Rate Limiting for SvelteKit projects. Create fine-grained access tokens to grant programatic access to your API.
If you have any kind of API publicly accessible on the internet then you need to protect it. You can block unwanted bots and automated requests but at some point, you may receive valid requests from legitimate users ... you just don't want them making too many requests too quickly as it can overload your backend and you want to ensure availability to other users by sharing out capacity evenly.
That's where rate limiting comes in. By defining a limit for how many requests a user makes, you can protect your backend resources. If you allow anonymous requests the throttling could be based on the IP address otherwise you'll likely want to provide API keys to identify the requestor and apply the limits based on those.
Which is the second part - how do you securely generate, store, and validate these keys? How do you protect against keys being exposed? Can you link API keys to accounts and apply different limits based on the account tier that should apply? How do you actually apply the limits and send the appropriate HTTP headers and response to inform the caller when limits are applied?
These are the things this package can help with.
Add to your project using your package manager of choice (tip: pnpm
is excellent):
pnpm install svelte-api-keys
The key store persists the information associated with an API key which is only ever accessed using the SHA256 hash of the key, for security purposes.
Provided implementations include an in-memory store, Firestore, and Redis. Other stores such as any popular RDBMS can be created by implementing a simple KeyStore
interface.
We'll use src/lib/api_keys.ts
to to store the code in all the following examples:
This uses an internal Map
which is not persisted so is suitable for development, testing and demonstration purposes only!
import { InMemoryKeyStore } from 'svelte-api-keys'
const storage = new InMemoryKeyStore()
Firestore is a popular cloud data store from Google. Use the firebase-admin/firestore
lib to create a Firestore instance and pass it to the FirestoreKeyStore
constructor. By default, key information is stored in a collection called api
but this can be overridden in the constructor. To save read costs and improve performance, wrap the store in an LruCacheKeyStore
instance:
import { initializeApp } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { FirestoreKeyStore, LruCacheKeyStore } from 'svelte-api-keys'
import { env } from '$env/dynamic/private'
const app = initializeApp({ projectId: env.FIREBASE_PROJECT_ID })
const firestore = getFirestore(app)
const storage = new LruCacheKeyStore(new FirestoreKeyStore(firestore))
Redis is a fast persistable cache and makes for an excellent store. Use the node redis
package to create a redis client instance and pass it to the RedisKeyStore
static create
method, which is used to ensure a search index exists. By default, key information is stored in a hash structure with the prefix api:
but this can be overridden in the constructor:
import { createClient } from 'redis'
import { RedisKeyStore } from 'svelte-api-keys'
import { env } from '$env/dynamic/private'
const redis = createClient({ url: env.REDIS_URL })
await redis.connect()
const storage = await RedisKeyStore.create(redis)
The token bucket store maintains the state of each token bucket.
Provided implementations include an in-memory store, and Redis. Other stores such as any popular RDBMS can be created by extending a base TokenBucket
class and implementing a consume
method.
This uses an internal Map
which is not persisted or shared so is suitable for single-server use where potentially allowing excess requests in the event of a process restart would be acceptable, or for development, testing and demonstration purposes only!
import { InMemoryTokenBucket } from 'svelte-api-keys'
const buckets = new InMemoryTokenBucket()
The Redis implementation uses a server-side javascript function to handle the token bucket update logic, so Redis Stack Server is recommended. This function is created automatically when the redis client instance is passed to the RedisTokenBucket
static create
method. You can also override the default storage prefix (bucket:
), module name (TokenBucket
), and function name (consume
) if needed.
The key store and token bucket implementations are independent of each other and can be mix-and-matched as required, but it's likely that if you're using redis you'll use the Redis implementations of both so they can be created using the same redis client instance:
import { createClient } from 'redis'
import { RedisKeyStore, RedisTokenBucket } from 'svelte-api-keys'
import { env } from '$env/dynamic/private'
const redis = createClient({ url: env.REDIS_URL })
await redis.connect()
const storage = await RedisKeyStore.create(redis)
const buckets = await RedisTokenBucket.create(redis)
The ApiKeys
manager provides the interface to generate, validate, and manage API keys. It uses the API Key Store internally, and applies SHA256 hashing to keys for security when storing and retrieving them (you can never leak keys if you don't store them!). Normally, you should never access the key store directly - aways use the key manager to do so. When generating keys, it will ensure a key doesn't contain any 'bad words' (which could otherwise be unfortunate and embarrassing!).
The simplest use just requires the key store and token bucket implementations be passed to it:
export const api_keys = new ApiKeys(storage, buckets)
There is an optional parameters object that can also control it's behavior by passing:
cookie
(default api-key
) sets the name of a cookie to inspect for an API Key on any incoming request.
httpHeader
(default x-api-key
) sets the name of an http header to inspect for an API Key on any incoming request. A request containing the http header x-api-key: my-api-key
would find the key my-api-key
automatically. Any key found in the http header will override a key found from a cookie.
searchParam
(default key
) sets the name of a URL search parameter to inspect for an API Key on any incoming request. A request for POST /my-endpoint?key=my-api-key
would find the key my-api-key
automatically. Any key found in the search param will override a key found from an http header or cookie.
custom
(default undefined) sets a custom key extraction & transform function that allows you to perform your own key lookups, perhaps via an existing session cookie or similar, and also allows you to transform any existing key that has been extracted using the previous settings - you might prefix keys to indicate their usage as Stripe does for instance. This will override all other methods if specified.
key_length
(default 32) sets the length, in bytes, of the API key to generate. If you want shorter API keys you could consider setting it to a lower value such as 24 or 16 (but too low risks conflicts when generating new keys). Keys are converted to human-readable format using Base62 for compactness and easy copy-paste.
So as a more complete example your src/lib/api_keys.ts
may end up looking something like this, but using whatever key store and token bucket implementations make sense for you:
import { ApiKeys, InMemoryKeyStore, InMemoryTokenBucket } from 'svelte-api-keys'
const storage = new InMemoryKeyStore()
const buckets = new InMemoryTokenBucket()
export const api_keys = new ApiKeys(storage, buckets, { searchParam: 'api-key', key_length: 16 })
The ApiKeys
instance we created provides a .handle
property that can be used to hook it into the SvelteKit request pipeline. Just return this from hooks.server.ts
:
import { api_keys } from '$lib/api_keys`
export const handle = api_keys.handle
If you already have a handle
function you can chain them together using the sequence
helper function.
Now our API Key manager is hooked into the SvelteKit pipeline, any request will have an api
object available on locals
. This will have a key
, and info
property depending on whether an API Key was sent with the request and whether it was valid. It also provides a fluent API that any endpoint can use to limit()
the request by passing in the refill rate to apply.
The simplest rate-limiting just requires awaiting a call to locals.api.limit(rate)
where rate
is a token-bucket refill rate and size:
import { json } from '@sveltejs/kit'
import { MINUTE } from 'svelte-api-keys'
import { fetchData } from '$lib/database'
const rate = { rate: 30 / MINUTE, size: 10 }
export async function POST({ locals }) {
await locals.api.limit(rate)
const data = await fetchData()
return json(data)
}
The rate
property is the rate-per-second that the token bucket refills. Read it like a fraction - numerator per denominator. To make it easier to define them, we've provided SECOND
, MINUTE
, HOUR
, DAY
, and WEEK
constants for the denominator. In the example above, 30 / MINUTE
would equate to a rate of 0.5 per second ... meaning a new token would be added every 2 seconds.
The size
property is the token bucket capacity. This provides both the initial size when a token-bucket is created and the total capacity that the bucket will ever fill upto. It will then allow a burst of that number of requests without any limiting being applied, at which point the requests have to wait for tokens to become available (at the refill rate).
If you don't want to hard-code the limits into the app, you can fetch them from a datastore or environment variables. They can be stored as a string and parsed. Note the units are case insensitive:
import { env } from '$env/dynamic/private'
import { parseRefill } from 'svelte-api-keys'
// SOME_ENDPOINT_RATE_LIMIT="30 / minute, 10"
const rate = parseRefill(env.SOME_ENDPOINT_RATE_LIMIT)
// identical to { rate: 30 / MINUTE, size: 10 }
With no other parameters, this applies rate limiting globally to the app - the limit would be shared for any endpoints using it (a separate count is kept for each API key though). If the call is approved, the endpoint request will complete as normal. If there are insufficient tokens in the bucket, the server will send a 429 Too Many Requests
response to indicate that the client needs to back-off and wait. Appropriate HTTP headers will be added to each response to communicate the limits to the caller - this can be used to avoid making a request that would not be approved, by waiting for the indicated time (how long before the token bucket will refill enough to allow it).
But we can do more ...
Not all API calls are equal, some may be more expensive and you want to account for this in the rate limiting. One easy way to do that is to just apply a different cost - consuming more tokens from the bucket. By default, 1 token is consumed per call, but this can be overridden:
await locals.api.cost(5).limit(rate)
If the refill rate was 3 per second, with a size capacity of 10, this would allow 2 initial calls to be made after which they would need to wait 1⅔ seconds between each. Again, this limit would be shared, so more of the smaller cost endpoints could be called in the same period of time.
But we can do more ...
Maybe you want to have different independent rate limits for different endpoints or groups of endpoints? By adding a name, the token-buckets will be separated:
await locals.api.name(`comments`).limit(rate)
But we can do more ...
Good practice is to not give too many permissions to a single key, but instead to limit it's use for a specific purpose. When generating an API key we can define a set of permissions that it has. Then, any endpoint can include the permissions when asking for approval - if the API key info doesn't have the necessary permissions the request will be denied with a 403
response.
// require a single permission:
await locals.api.has(`get`).limit(rate)
// require a complete set of permissions:
await locals.api.all([`get`, 'comments']).limit(rate)
// require any of the permissions specified:
await locals.api.all([`get`, 'read', 'search']).limit(rate)
But we can do more ...
OK, last one, I promise. If you have an anonymous endpoint, there won't be any API key provided, and no KEY info to check against, so the permission checks won't be used. But we can still apply rate limiting and allow a call to be made without a key (otherwise, a missing key would result in a 401
response and an invalid or expired key would return 403
):
await locals.api.anonymous().limit(rate)
All of these options can be combined into a single call, just make sure that the .limit(rate)
call is last:
await locals.api.name('posts').has('get').cost(2).limit(rate)
Sometimes, the rate limit that should apply will depend on the user account that the key belongs to. You may have different usage tiers such as free
, basic
, premium
, enterprise
, and so on. This can be accomplished by using a sequence
of hooks to lookup the appropriate tier based on the user (available in KEY info).
First, add the appropriate tiers to App.Locals
in src/app.d.ts
:
interface Locals extends ApiLocals {
tier: 'basic' | 'premium' | 'enterprise'
}
Add an additional handler to src/hooks.server.ts
:
import { sequence } from '@sveltejs/kit/hooks'
import type { Handle } from '@sveltejs/kit'
import { fetchTierForUser } from '$lib/database'
import { api_keys } from '$lib/api_keys`
// this handle could set the locals.tier based on the api.info.user
const handleTiers: Handle = async ({ event, resolve }) => {
const { locals } = event
// fetchTierForUser is an example API that will return the appropriate tier based on the key info user
// tip: this would benefit from an in-memory LRU + TTL cache to avoid slowing down repeated lookups...
locals.tier = await fetchTierForUser(locals.api.info)
return await resolve(event)
}
// the handle we export is now a sequence of our api_keys handler and this one
export const handle = sequence(api_keys.handle, handleTiers)
Now our endpoints have access to a locals.tier
value which can be used to select an appropriate token-bucket refill rate:
import { json } from '@sveltejs/kit'
import { MINUTE } from 'svelte-api-keys'
import { fetchData } from '$lib/database'
const rates = {
basic: { rate: 10 / MINUTE, size: 1 },
premium: { rate: 60 / MINUTE, size: 20 },
enterprise: { rate: 300 / MINUTE, size: 60 },
}
export async function POST({ locals }) {
const { tier } = locals
const rate = rates[tier]
await locals.api.limit(rate)
const data = await fetchData()
return json(data)
}
Finally, should you need them for whatever reason, the .limit(rate)
method returns details about the result of the call which are also set as HTTP Response headers - these will allow well-behaved clients to automatically back off when they hit rate limits.
Possible enhancements:
.limit(rate)
, at least after any other api methods