HomeGuidesLeast privilege API keys: Stripe, OpenAI, GitHub, Google

Guide

Least privilege API keys: Stripe, OpenAI, GitHub, Google

Scope every key your app uses: Stripe restricted keys, OpenAI project keys, GitHub fine-grained tokens, Supabase secret keys and minimal Google scopes.

19 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

Most vibe coded apps run on a handful of keys: Stripe, an AI provider, Supabase or Firebase, an email API, maybe GitHub or Google. Setup tutorials often use the most powerful key the provider has, and the AI tool copies it into .env. That key works for everything, so it also works for anyone who gets a copy.

This guide shows how to swap each one for a key that can do only what your app actually calls. It covers Stripe restricted keys, OpenAI project keys, GitHub fine-grained tokens and GITHUB_TOKEN permissions, Supabase publishable and secret keys, scoped Supabase personal access tokens, Resend sending keys, and Google and GitHub OAuth scopes for sign in. If a key already leaked, start with the first hour after an API key leaks and come back here for the replacement.

Sources: the provider docs linked at the end, read on October 27, 2026. Dashboards change, so check the wording on your own screen. We did not create or test keys in anyone's account for this guide. VibeLocked is independent and not affiliated with any of these providers.

The short version

  1. List what each key actually does. Which part of your app uses it, and which API calls it makes.
  2. Stripe: use a restricted key (rk_), not the secret key (sk_). Start from None and add Read or Write per resource.
  3. OpenAI: one project per app, a key set to Restricted or Read Only where it fits, a model allowlist, and a project spend limit.
  4. GitHub: a fine-grained token for selected repositories with minimal permissions and an expiry. In Actions, set permissions: on the workflow or job.
  5. Supabase: publishable key in the browser, secret key only on the server, one secret key per backend component.
  6. Email: a Resend key with sending access only, limited to your sending domain.
  7. OAuth sign in: ask only for the identity scopes. Ask for Drive, Gmail or repo access later, when the user opens that feature.
  8. Every scoped key is still a secret unless the provider says it is publishable. Least privilege limits the damage of a leak. It does not make a leak safe.

Why it matters

Stripe's best practices page puts the rule plainly: "Follow the principle of least privilege: People and systems must have only the privileges required to perform their jobs."

The point is the size of the blast radius. Stripe's restricted key docs give an example: a key that can only read dispute data. "If a bad actor obtained that key, they could only read dispute data. They couldn't create charges, access customer payment methods, or trigger payouts."

The same idea applies to every provider below. Keys leak through public repos, browser bundles, logs, AI chat history and MCP configs. You cannot promise that never happens. You can make sure the key that leaks is a small one. A tampered editor extension can reach them too: after the malicious Claude Code extension in Cursor's marketplace tried to plant an SSH backdoor on developer machines on October 29, 2026, Cursor told affected users to rotate the credentials those machines could reach.

Step 1: list your keys and what they do

Before you change anything, write down every key the app uses. Your AI tool can do this for you with the prompt near the end of this guide. For each key, note:

  • Where it runs. Browser, mobile app, server route, Edge Function, cron job, CI, an MCP server on your laptop.
  • What it calls. The SDK methods or endpoints, for example "create checkout session" or "send email".
  • Whether it writes. Reads and writes usually get separate permissions.
  • Who else holds it. A contractor, a third party service, an AI agent.

If two parts of the app share one key, plan to split them. Stripe and Supabase both recommend one key per service or component, so that one leak means rotating one key.

Stripe: restricted keys instead of sk_

Stripe has three kinds of keys you will meet: publishable (pk_), restricted (rk_) and secret (sk_). The API keys page says the publishable key "can't perform sensitive operations such as creating charges or reading account data", which is why it can sit in front end code. About the secret key it says: "Because you can't limit their permissions, we don't recommend using secret keys for new use cases, and for existing integrations, we recommend migrating secret key usage to RAKs."

A restricted API key (RAK) is the replacement. From the restricted key docs: "Unlike a secret key that can do anything in your Stripe account, a RAK can do only what you give it permission to do." Each resource gets None, Read or Write, and "The default value for all permissions is None."

How to work out the permissions, using Stripe's own methods:

Method What you do
Read your code Map each SDK call to a resource. Stripe's examples: creating a PaymentIntent needs Payment Intents Write, retrieving a Customer needs Customers Read.
Read the request logs On the API keys page, open View request logs for the key. GET requests need Read, POST and DELETE need Write.
Test in a sandbox Create the restricted key in a sandbox first, run every flow, and add what fails. Stripe says the error response names the permission to add.

Two details from those docs that save time: "Write permissions imply read permissions: any key that can write an API resource can also read that resource." And restricted keys are drop in replacements, so your SDK code does not change, only the key value.

More Stripe controls worth knowing:

  • One restricted key per service. Stripe suggests separate keys for, say, a billing service, a reporting service and a webhook handler.
  • Access policies. You can attach a policy so a key only works from listed IP ranges, or by ASN, country and blocked sources such as Tor exit nodes. Stripe recommends policies on all live mode keys.
  • AI agents. Stripe says restricted keys are the recommended type for keys you give to AI agents, and you can tag a restricted key as an agent key so approval rules apply to sensitive actions such as payouts and refunds.
  • Still a secret. Stripe says to "Treat RAKs with the same caution as secret API keys." A restricted key in the browser is a leak.

The webhook signing secret (whsec_) is separate from API keys. Keep it on the server too, and see securing Stripe webhooks for how to verify events.

OpenAI: one project per app, restricted keys, a spend limit

OpenAI keys live inside projects. The projects help article says: "A personal API key created within a project is scoped to that project and its resources." Files, assistants, storage and threads are also scoped to their project.

For user owned keys you pick one of three permission levels when you create or edit the key:

  • "All: Full permissions. This is the default."
  • "Restricted: Choose scopes for specific resources or endpoints."
  • "Read Only: Read permissions for all endpoints."

Service account keys work differently. The same article says: "By default, the service account key can read and write all API resources in its project." The permission controls are not in the creation dialog for them, so you change them afterwards in the project's API Keys settings. If your production server uses a service account key, check that step was done.

A least privilege OpenAI setup for one app:

  1. A separate project for each app, and for each environment if you can. A preview or staging key should not share a budget with production (preview deployments and env vars).
  2. A key set to Restricted with only the endpoints your server route calls. If a call fails with a permissions error, add that one scope instead of switching back to All.
  3. Model usage limited on the project's Limits page to the models the app uses.
  4. A monthly project spend limit with Enforce a hard limit turned on. OpenAI notes that "Enforcement isn't instantaneous, so recorded spending can slightly exceed the configured amount." Alerts alone do not stop requests.

A scoped key does not stop strangers from using your AI route as a free proxy. That needs auth and rate limits on your side: stop strangers from running up your OpenAI bill.

GitHub: fine-grained tokens and GITHUB_TOKEN permissions

You might hand a GitHub token to a deploy script, a CI job, an AI agent or an MCP server. GitHub's token docs recommend fine-grained personal access tokens over classic ones "whenever possible", and spell out the risk of classic tokens: "If you choose to use a personal access token (classic), keep in mind that it will grant access to all repositories within the organizations that you have access to, as well as all personal repositories in your personal account."

A fine-grained token is limited to one resource owner (you or one organization), can be limited to selected repositories, and gets specific permissions instead of broad scopes. When you create one:

  • Resource owner: the account or organization that owns the repo, nothing else.
  • Repository access: Only select repositories. GitHub's own instruction is "You should choose the minimal repository access that meets your needs." Note that "Tokens always include read-only access to all public repositories on GitHub."
  • Permissions: the smallest set. Reading code is Contents Read. GitHub's own example of a read only token pairs contents:read with metadata:read. Write includes read, and admin includes write.
  • Expiration: set one. GitHub removes tokens unused for a year, and recommends adding an expiry on top of that.

Fine-grained tokens still have gaps. GitHub lists cases such as contributing to repos where you are an outside collaborator, accessing Packages and calling the Checks API. Only fall back to a classic token for those, and pick the narrowest scope.

Inside GitHub Actions, prefer the built in GITHUB_TOKEN over a personal token, and limit it. GitHub's GITHUB_TOKEN docs say: "As a good security practice, you should grant the GITHUB_TOKEN the least required access." They also warn that an action can read the token through the github.token context even if your workflow never passes it. Set permissions per workflow or per job:

permissions:
  contents: read

Add more only for the jobs that need them, for example issues: write for a job that opens issues.

Token scope is only half of it. A workflow on pull_request_target that runs code from a fork's pull request gets your token and your repository secrets, whatever the scopes. How that happens, and what the runner's log masking does and does not hide, is in pull_request_target and secret masking, tested.

For MCP servers specifically, including GitHub's read only mode and per project tokens, see MCP server security.

Supabase: publishable key in the browser, secret keys per component

Supabase keys are least privilege by design, as long as each one stays where it belongs. From the API keys docs: the publishable key (sb_publishable_...) is safe to ship and maps to the anon or authenticated role, so Row Level Security decides what it can reach. Secret keys (sb_secret_...) "provide full access to your project's data, bypassing Row Level Security."

There is no scoped secret key, so least privilege here means three things:

  • The browser never needs a secret key. If a feature only works with one, the fix is an RLS policy or a server route, not the key. Anon key vs service role key explains why.
  • One secret key per backend component. Supabase: "Use a separate secret key for each backend component. If one component leaks its key, you only rotate that one."
  • Do not lean on the browser block. Supabase says a secret key sent from a browser gets HTTP 401, but also: "The browser block returns HTTP 401 Unauthorized, but an attacker can still use the key from other tools."

Supabase's legacy anon and service_role keys keep working until you disable them, and Supabase says they are being deprecated by the end of 2026. When you migrate, give each server component its own new secret key instead of one shared copy.

Scoped personal access tokens for CI, scripts and agents

Data keys are half the story. The Supabase CLI, the Supabase MCP server and Management API scripts use a personal access token (PAT) instead. Supabase's scoped tokens announcement puts the old problem plainly: a PAT "can do anything you can do in your Supabase account, across every organization and project you belong to." Now you can scope one.

What you can scope, per the Personal Access Tokens docs and the announcement:

  • Resources. Organizations or projects. The Dashboard recommends a project level scope.
  • Permissions. Each one is Read or Read-write on one resource, such as Database, Migrations, Edge Functions or API Keys.
  • Expiry. The blog says you can set "a custom expiration date up to one year out."

Tokens you create in the Dashboard are scoped by default. Full access tokens still exist and carry a Legacy badge. The docs warn that one covers every organization and project you belong to, "including ones you create or join later."

How to create one, following the steps in Supabase's blog:

  1. Open your access tokens page in the Supabase Dashboard.
  2. Choose the resources. For one app, pick that single project.
  3. Choose the permissions. The blog says to start with the No access preset, "because 'Read-only' includes secrets like API keys", then turn on only what the job needs.
  4. Set an expiry that matches the job.
  5. Review the blast radius. The review screen lists the Management API endpoints and MCP tools each permission covers, plus a Low, Medium or High risk banner.
  6. Copy the token. It is shown only once. Scoped tokens start with sbp_fc.

Scope and permissions are fixed at creation, so to change them you create a replacement token. A token also never has more access than your own account. If your role loses a permission, the token loses it too.

Example: an agent that only reads. Scope the token to one project and grant only Database with Read access. The docs' MCP tools table maps that to execute_sql, list_tables and generate_typescript_types. The blog says a SQL write then "returns a Postgres permission error rather than a 403." apply_migration needs Migrations with Read-write access, which this token does not have.

Example: a CI job that applies migrations. Give the job its own token, scoped to that one project, with an expiry. Store it as a CI secret named SUPABASE_ACCESS_TOKEN. The docs explain: "Because the environment variable takes precedence over any saved token, it's the right choice for CI." Two details matter:

  • The docs say supabase link "needs Project Settings, API Keys, and API Key Secrets, all with Read access."
  • Database commands that connect with your database password, such as supabase db push with SUPABASE_DB_PASSWORD set, "aren't limited by the token's permissions." In that job the database password is the more powerful secret, so protect it the same way.

If the job applies migrations through the Management API or the MCP server instead of a database password, the docs list Migrations with Read-write access for that.

Be careful with API Key Secrets. That permission lets the token read your project's secret API keys. Supabase's API keys docs say secret keys "provide full access to your project's data, bypassing Row Level Security." So a token with that permission is as sensitive as your secret key. Grant it only when the job truly needs it, like a CI job that runs supabase link. An agent rarely does. For example, the MCP tool get_publishable_keys needs only API Keys with Read access.

Existing full access tokens keep working until they expire or you delete them, so nothing forces the switch. Replace them yourself, starting with the ones in CI and agent configs. For the MCP side, see MCP server security.

Email: Resend sending only keys

An email API key with full access can manage everything in the account. Resend's create API key reference lists two permissions:

  • "full_access: Can create, delete, get, and update any resource."
  • "sending_access: Can only send emails."

A sending access key can also be restricted to one domain: "Restrict an API key to send emails only from a specific domain." Your app's contact form or signup email usually needs exactly that. Resend's API keys page also suggests separate keys per action so you can see logs per key, and deleting keys unused for 30 days.

OAuth: ask for sign in scopes only, then ask again later

OAuth works the other way round. The scopes you request decide how much of your users' data your app, and anyone who steals its tokens, can reach.

Google. Google's scopes list gives three scopes for Google Sign-In: openid, email and profile. That is all sign in needs. Google's OAuth best practices say: "You should not request access to data when the user first authenticates, unless it is essential for the core functionality of your app." Instead, request each extra scope when the user picks the feature that needs it, "following the principle to select the smallest, most limited scopes possible", and handle a user who says no by turning that feature off.

If your app touches Google Drive, the choice is concrete. Google's Drive scopes guide lists drive.file as non sensitive: it covers files the user opens with your app or picks in the Google Picker. The full drive scope ("View and manage all your Drive files.") and drive.readonly are restricted scopes that need restricted scope verification, and Google adds: "If you store restricted scope data on servers (or transmit), then you must go through a security assessment."

GitHub sign in. Supabase's signInWithOAuth reference has an example that passes scopes: 'repo gist notifications' to get a provider token. AI tools copy examples like this. GitHub's OAuth scopes list says repo "Grants full access to public and private repositories including read and write access to code". If your app only signs people in, leave scopes out of the call. If it needs an email address, user:email "Grants read access to a user's email addresses."

Whatever scopes you get, the tokens are secrets too. Google says to store user tokens securely and to "Revoke tokens as soon as they are no longer needed". That same Supabase example saves provider tokens in localStorage, where any script on the page can read them; where to store a JWT covers the tradeoff.

Quick reference

Provider Broad key Least privilege option
Stripe Secret key sk_ Restricted key rk_, None/Read/Write per resource, access policy
OpenAI Key set to All Project per app, Restricted or Read Only key, model list, hard spend limit
GitHub Classic token, repo scope Fine-grained token, selected repos, expiry. permissions: in Actions
Supabase One shared secret key Publishable key in browser, one secret key per server component
Supabase CLI, MCP, CI Full access (Legacy) token Scoped token: one project, only needed permissions, expiry
Resend full_access sending_access for one domain
Google OAuth drive, Gmail, Calendar at sign in openid email profile, extra scopes later, drive.file

Checklist

  • Every key the app uses is listed with where it runs and what it calls
  • No Stripe sk_ key in new code; each server component has its own rk_ key with only the permissions it uses
  • Live Stripe keys have an access policy where your hosting allows it
  • Each app has its own OpenAI project, a Restricted key where possible, a model list and a hard spend limit
  • Service account keys on OpenAI were edited after creation, not left at full read and write
  • GitHub tokens are fine-grained, limited to selected repos, with an expiry; workflows set permissions:
  • No Supabase secret or service_role key in the browser, and each backend component has its own secret key
  • Supabase tokens for CI, scripts and agents are scoped to one project, with only the permissions the job uses and an expiry
  • Only tokens that truly need it (for example supabase link in CI) have API Key Secrets read
  • Email keys are sending only and limited to your domain
  • Sign in asks only for identity scopes; extra OAuth scopes are requested when the feature is used
  • Unused keys and OAuth clients are deleted

Common AI generated mistakes

  • Using sk_live_ for everything because the Stripe quickstart did. Ask for restricted keys per service instead.
  • Switching an OpenAI key back to All after one missing scope error, instead of adding the one scope.
  • Classic GitHub tokens with repo for a job that only reads one repo, or no permissions: block in a workflow.
  • The Supabase secret key in a client component to get past an RLS error. Fix the policy instead.
  • Copying OAuth examples with extra scopes, like repo on a GitHub sign in button or full drive for a file picker.
  • A full access Supabase token in CI or an MCP config, when one project and one or two permissions would do.
  • One key shared by dev, preview and production, so a preview leak spends the production budget.
  • Putting a restricted key in front end code because it is "restricted". Only publishable keys belong there.

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Codex, Lovable, Bolt, or Replit inside your own project. It works from variable names and code only. Do not paste key values, tokens, or .env contents into the chat.

Do not print, log, or ask me for any secret values. Work only from variable names and code. 1) List every environment variable that holds an API key, token, or OAuth client secret, and for each one say where it is used (browser, server route, edge function, cron, CI workflow, MCP config) and which provider calls it makes. 2) Flag any secret that reaches browser code, including variables with NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_ or PUBLIC_ prefixes, and any Stripe sk_ or rk_ key, Supabase secret or service_role key, OpenAI key, or GitHub token in client code. 3) For Stripe, list every SDK call per file and the resource and Read or Write permission each needs, so I can create one restricted key per service. 4) For OpenAI, list the endpoints the code calls so I can set a Restricted key. 5) For GitHub Actions workflows, add a minimal permissions block to each workflow or job. 6) List every OAuth scope the app requests and flag any scope not needed at sign in. 7) Suggest one key per component where several share one. Do not change any key values or dashboard settings. Show me the plan first.

More prompts are in the security review prompt guide.

Check what your live app exposes with LeakCheck

LeakCheck scans any live app URL or public GitHub repo, read only, wherever it is hosted. It looks for keys that should not be there: provider secrets in your JavaScript bundles or committed to a public repo, exposed files such as .env, and, on apps that use Supabase, whether the key the app ships can read your tables.

Its limits, honestly: it cannot see your Stripe, OpenAI, GitHub or Google dashboards, so it cannot tell you whether a server side key is restricted or what OAuth scopes you request. It never sends writes, and it does not call Stripe, OpenAI or GitHub with a key it finds. The inventory and the checklist above are how you shrink the keys you hold; LeakCheck tells you whether any of them is already public. If one is, follow the first hour after an API key leaks and check if your API key leaked on GitHub.

Sources

Questions

What is a least privilege API key?

A key that can do only the calls one part of your app makes, and nothing else. If it leaks, the damage stops at those calls. Stripe restricted keys, OpenAI keys set to Restricted, GitHub fine-grained tokens and Resend sending only keys are all ways to get there. A full access key is the opposite: one leak and the attacker can do anything you can.

Should I use a Stripe restricted key instead of the secret key?

For new code, yes. Stripe's API keys page says it does not recommend secret keys for new use cases and recommends migrating existing secret key usage to restricted keys. A restricted key starts with rk_live_ or rk_test_, every permission starts at None, and you set Read or Write per resource. It is still a server secret, so it never goes in the browser.

How do I find which Stripe permissions my restricted key needs?

Stripe suggests two ways. Search your code for Stripe SDK calls and map each one to a resource permission, or look at the key's request logs: GET requests need Read, POST and DELETE need Write. Test in a sandbox first. If a call is missing a permission, the error response tells you which permission to add.

Can I restrict an OpenAI API key?

Yes. Keys live in a project, and a user owned key can be All (the default), Restricted, where you choose scopes per resource or endpoint, or Read Only. Service account keys can read and write everything in their project by default, and you change that afterwards in the project's API Keys settings. Give each app its own project, so the key, the model list and the spend limit only cover that app.

Is a GitHub fine-grained token safer than a classic token?

Usually. A fine-grained token belongs to one user or organization, can be limited to selected repositories, and gets specific permissions. GitHub says a classic token can reach every repository you can access. Fine-grained tokens still cannot do everything, for example contributing to repos where you are an outside collaborator, so check GitHub's list of gaps before you fall back to classic.

What Google OAuth scopes do I need just to sign users in?

For sign in, Google lists openid, email and profile. Anything that reads Drive, Gmail or Calendar is a separate scope that you should request later, when the user opens the feature that needs it. Google calls this incremental authorization. Broad scopes like drive are restricted scopes that need extra verification, so prefer drive.file when users pick the files your app may touch.

Can I scope a Supabase personal access token?

Yes. Supabase personal access tokens, used by the CLI, the MCP server and the Management API, can be scoped to organizations or projects, to specific permissions with Read or Read-write access, and to an expiry up to one year out. Tokens created in the Dashboard are scoped by default. For CI or an agent, pick one project and only the permissions the job uses. Avoid API Key Secrets unless the job needs it, since it exposes your secret keys.