Skip to main content

User-Scoped API Keys

Issue, list, and revoke API keys on behalf of your application's end users — without building your own key infrastructure.

If your product (a calendar, an inbox, a profile platform, anything) wants to expose programmatic access to your users — "create a personal access token to use my account from a script" — ZewstID handles the boring parts: secure generation, hashing, scoping, rotation, revocation, and validation. You provide the UI and decide what the keys are for.

This is what GitHub does with PATs, what Stripe does with restricted keys, and what Clerk does with user API keys — except you don't have to build any of it.


Architecture

┌──────────────────┐ ┌────────────────┐ │ Your App │ ① OAuth login │ ZewstID │ │ (Cal / Mail / │ ──────────────────────► │ account. │ │ your product) │ │ zewstid.com │ │ │ └────────────────┘ │ ② Settings page │ ③ Issue key on ▲ │ shows the keys │ behalf of user │ │ the user owns │ ────────────────────────► │ │ │ (M2M, your service │ │ │ account creds) │ └──────────────────┘ ◄─────────────────────── ┌────┴───────────┐ ▲ key (shown once) │ ZewstID API │ │ │ api.zewstid. │ │ ④ End user calls your API: │ com │ │ Authorization: Bearer zw_live_... │ │ │ │ Stores keys, │ │ ⑤ Your API → introspect ─────────────►│ validates, │ │ { active, sub, scopes, ... } │ rate-limits │ └───────────────────────────────────── └────────────────┘

Three primary endpoints, all M2M-authenticated:

EndpointPurpose
POST /api/v1/applications/:appId/users/:sub/api-keys
Issue a key for an end user
GET /api/v1/applications/:appId/users/:sub/api-keys
List a user's keys
DELETE /api/v1/applications/:appId/users/:sub/api-keys/:keyId
Revoke
POST /api/v1/api-keys/introspect
Validate a presented key

Plus an admin lister:

GET /api/v1/applications/:appId/api-keys?kind=end_user
.


One-time setup

1. Provision a service account

Developer Portal → Service AccountsNew → grant:

  • api-keys:issue
  • api-keys:read
  • api-keys:revoke

The service account is automatically tied to your application — only your app can issue keys on its own users' behalf.

2. Configure your env

ZEWSTID_CLIENT_ID=sa_xxxxxxxxxxxx # service account ZEWSTID_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx ZEWSTID_APP_ID=app_xxxxxxxxxxxx # your application's public id

That's it. The SDK auto-discovers token + introspection endpoints.


SDK usage (Next.js)

Issuing a key

// app/api/me/api-keys/route.ts import { getServerSession } from 'next-auth/next'; import { getUserApiKeysClient } from '@zewstid/id-nextjs/server'; import { authOptions } from '@/lib/auth'; export async function POST(req: Request) { const session = await getServerSession(authOptions); if (!session?.user?.zewstIdSub) { return new Response('Unauthorized', { status: 401 }); } const { name, scopes } = await req.json(); const keys = getUserApiKeysClient(); const { apiKey } = await keys.create({ forUserSub: session.user.zewstIdSub, name, scopes, expiresInDays: 90, }); // apiKey.key is shown ONCE — return it to the client, then drop it. return Response.json({ apiKey }); }

Listing a user's keys

const { apiKeys } = await getUserApiKeysClient().list(session.user.zewstIdSub); // → [{ keyId, name, keyPrefix, scopes, lastUsedAt, ... }]

Revoking

await getUserApiKeysClient().revoke(session.user.zewstIdSub, keyId);

Validating a key your API received

// In your API middleware import { getUserApiKeysClient } from '@zewstid/id-nextjs/server'; export async function authenticate(req: Request) { const token = req.headers.get('authorization')?.replace(/^Bearer /i, ''); if (!token) throw new Response('No token', { status: 401 }); const result = await getUserApiKeysClient().introspect(token); if (!result.active) throw new Response('Invalid token', { status: 401 }); return { userSub: result.forUserSub, // who the request is for scopes: result.scopes ?? [], // what they can do appId: result.appId, // sanity check — is this YOUR app? }; }

SDK usage (Node)

import { ZewstIDUserApiKeys } from '@zewstid/id-node'; const keys = new ZewstIDUserApiKeys({ clientId: process.env.ZEWSTID_CLIENT_ID!, clientSecret: process.env.ZEWSTID_CLIENT_SECRET!, appId: process.env.ZEWSTID_APP_ID!, }); const { apiKey } = await keys.create({ forUserSub: 'user_xxx', name: 'CI deploy token', scopes: ['cal:read', 'cal:write'], expiresInDays: 90, }); await keys.revoke('user_xxx', apiKey.keyId);

Drop-in UI component

For the typical "settings page" experience, the

<ZewstIDApiKeysManager />
component handles the entire flow — list, create with scope picker, copy-once secret reveal, revoke — in ~10 lines:

// app/(dashboard)/settings/api-keys/page.tsx 'use client'; import { ZewstIDApiKeysManager } from '@zewstid/id-nextjs'; export default function ApiKeysSettings() { return ( <ZewstIDApiKeysManager // Wire to your own server-side endpoints that proxy to ZewstID endpoints={{ list: '/api/me/api-keys', create: '/api/me/api-keys', revoke: (keyId) => `/api/me/api-keys/${keyId}`, }} availableScopes={[ { value: 'cal:read', label: 'Read calendar events' }, { value: 'cal:write', label: 'Create / edit events' }, ]} /> ); }

The component is purely UI — your server-side route handlers (using

getUserApiKeysClient()
) own the actual ZewstID calls. This keeps your service-account credentials on the server, never reaching the browser.


Key format and validation

FieldValue
Format
zw_live_<48 random chars>
(production) /
zw_test_<48 random chars>
(sandbox). Legacy
sk_live_*
/
sk_sandbox_*
keys keep validating until rotated.
Visible prefixFirst 8 chars +
...
— safe to display in your UI
Hashingbcrypt (cost 10) — never stored in plaintext
Returned at creationOnce. After that, only the prefix is recoverable.

Validating without round-tripping

For high-throughput APIs, you can verify keys locally if you cache the introspection result. Recommended pattern:

  1. First request with a new key: introspect, cache
    { scopes, forUserSub }
    in Redis for 5 minutes
  2. Subsequent requests: hit the cache, skip the round-trip
  3. On revocation in your settings UI: invalidate the cache for that key

Don't try to verify keys offline by re-hashing — bcrypt is slow on purpose, and you'd duplicate ZewstID's storage. The introspection endpoint is fast enough for almost any workload.


Security notes

Show the key once. The introspection-based design means we never store the plaintext, so we can never re-display it. Make this very obvious in your UI — copy buttons, "save now" warnings, ideally a one-time download.

Scope your keys narrowly. The whole point is least-privilege. Don't issue

cal:*
if the user just needs
cal:read
.

Set expiry by default. Suggest

90d
in your UI. Power users will override; casual users will benefit from rotation hygiene.

Revoke on suspicious activity. Surface the

lastUsedAt
and
usageCount
fields. If a key suddenly spikes from 10 req/day to 10000, that's a signal.

Bind to user identity. The

forUserSub
field is your contract — your API must always check that an introspection result's
forUserSub
matches the actor the request claims to be. Otherwise, a user's leaked key could be reused under another user's identity.


Pricing

User-scoped API keys are included in your ZewstID plan with no per-key cost. Rate limits apply to issuance (60 keys / minute / app) and read operations (600 / minute), which is far above any real workload. Validation through introspection has its own much higher rate limit — designed to be on every request.


See also

Was this page helpful?

Let us know how we can improve our documentation