Creating an API Key

Ma Mahalakshmi V Updated 13 Sep 2026
20 min read ·Lesson 3 of 10

What an API Key Actually Is

An API key is a long secret string that identifies your account to OpenAI's servers. It travels with every request in an HTTP header:

Authorization: Bearer sk-proj-abc123...

The word Bearer in that header is not decoration — it is a precise description of the security model. A bearer token grants access to whoever bears it. There is no second factor, no device check, no password prompt. If someone has your key, they are you as far as the API is concerned. They can send requests, and every token those requests consume is charged to your credit balance.

That single property explains every practice in this lesson. A key is not a username; it is closer to a credit card number that also happens to be your login.

Three things are attached to a key:

  • Identity — which organisation and project the request belongs to.
  • Billing — whose balance gets debited.
  • Limits — which rate limits and spend caps apply.

The Key Hierarchy: Organisation, Project, Key

Before creating a key, it helps to know what it will be attached to.

An organisation is the billing entity — the account that holds credit and receives invoices. Inside it are projects, each of which is an isolation boundary with its own API keys, its own usage tracking, its own rate limits, and optionally its own spend limits and model restrictions. Keys belong to projects.

This structure is worth using rather than ignoring, for three concrete reasons:

  • Attribution. Usage is reported per project, so separate projects answer "which application spent the money" without any work on your part. One project for everything means one undifferentiated number.
  • Blast radius. Revoking a leaked key kills exactly the one project that used it. Everything else keeps running.
  • Containment. A spend limit on a learning project bounds the cost of a mistake to an amount you chose in advance.

For this course, create a dedicated project. Name it something like openai-course. It costs nothing and means every experiment you run is isolated and measurable.

Creating the Key

In your OpenAI dashboard at platform.openai.com:

  1. Select (or create) the project you want the key to belong to.
  2. Open the API keys page.
  3. Choose Create new secret key.
  4. Give it a descriptive name — laptop-course-dev, not key1. When you have six keys and need to revoke one, the name is the only thing telling you which is which.
  5. Set the permission level (see below).
  6. Create it, then copy the key immediately.

The key is displayed exactly once. OpenAI stores a hash, not the key itself, so there is no "show me that key again" option. Lose it and your only recourse is to delete it and create a new one. Paste it somewhere safe — ideally straight into the .env file you are about to create — before you close that dialog.

Key Permissions

When creating a key you can set its permission level:

LevelWhat it allowsUse for
AllFull access to every endpoint. The default.Local development where you will use many endpoints
RestrictedOnly the endpoint groups you selectProduction services that call two or three endpoints
Read onlyRead access across endpoints; no generation, no writesDashboards, monitoring, usage reporting

The principle behind these is least privilege: a key should be able to do what its application needs and nothing more. A leaked read-only key exposes information; a leaked full-access key exposes information and your entire credit balance.

For this course, an All key is the practical choice — you will touch responses, files, embeddings, images and audio across the fourteen units, and fighting permission errors while learning the API is a distraction. When you later deploy something real that only generates text, come back and issue it a restricted key. That is the moment least privilege earns its keep.

How the SDK Finds Your Key

Here is the mechanism that makes everything else in this lesson possible:

from openai import OpenAI

client = OpenAI()

That constructor, called with no arguments, reads the environment variable OPENAI_API_KEY and uses its value for the Authorization header on every request. If the variable is missing, constructing the client raises an OpenAIError telling you the key was not found.

Why an environment variable, and not a config file or a constructor argument? Because environment variables are the one mechanism that is available in every deployment target — your laptop, a Docker container, a CI runner, a serverless function, a managed hosting platform — without a file needing to exist. Configuration that varies by environment lives outside the code, so the same artefact runs everywhere with different values injected. The SDK defaults to reading OPENAI_API_KEY precisely so that the safe thing is also the zero-effort thing.

You can pass the key explicitly:

client = OpenAI(api_key="sk-proj-abc123...")   # do not do this

This is legal and it is how keys end up on GitHub. A literal in source code gets committed, pushed, copied into a gist, pasted into a bug report, and screenshotted in a tutorial. There is one narrow legitimate use — reading a key from a secrets manager at runtime and handing it to the constructor — and that is not a string literal:

import boto3
from openai import OpenAI

secret = boto3.client("secretsmanager").get_secret_value(SecretId="openai/prod")
client = OpenAI(api_key=secret["SecretString"])

Setting the Environment Variable

Temporarily, for one shell session

# macOS / Linux
export OPENAI_API_KEY="sk-proj-your-key-here"

# Windows PowerShell
$env:OPENAI_API_KEY = "sk-proj-your-key-here"

# Windows Command Prompt
set OPENAI_API_KEY=sk-proj-your-key-here

This lasts until you close the terminal. It is genuinely useful for a quick test or when running something on a machine you do not own, and it has a real security advantage: nothing is written to disk.

It also has a real cost. Open a new terminal tab and the variable is gone, and the resulting OpenAIError looks like a code problem when it is a shell problem. There is a second cost people miss: the command is written to your shell history file, so your key ends up in plain text in ~/.bash_history or ~/.zsh_history. On bash and zsh, prefixing the command with a space usually keeps it out of history — but only if HISTCONTROL/setopt HIST_IGNORE_SPACE is configured to do that.

Permanently, in your shell profile

Append the export line to ~/.zshrc (zsh, the macOS default), ~/.bashrc (bash on Linux), or ~/.bash_profile, then run source ~/.zshrc or open a new terminal.

On Windows, setx OPENAI_API_KEY "sk-proj-..." writes the variable permanently to the user environment — but note it does not affect the current session, only new ones, which trips people up constantly.

This works, and it is fine for a single personal key on a personal machine. It stops working well the moment you have two projects with different keys, because a machine-wide variable cannot have two values. That is what .env files solve.

The .env File Approach

A .env file is a plain text file, sitting in your project folder, holding that project's configuration. A small library reads it and loads the values into the process environment at startup.

Install the library:

python -m pip install python-dotenv

Create .env in your project root:

OPENAI_API_KEY=sk-proj-your-actual-key-here

Note the format: KEY=value, no export, no spaces around =, and no quotes unless the value genuinely contains spaces. Quotes that are not needed become part of the value in some parsers, which produces an authentication error with a key that looks perfectly correct on screen.

Load it before creating the client:

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()          # reads .env and populates os.environ

client = OpenAI()      # now finds OPENAI_API_KEY

Order matters. load_dotenv() must run before OpenAI() is constructed, because the constructor reads the environment at the moment it is called. Putting load_dotenv() after client creation is a real and common bug: the file is read, the variable is set, and the client that already failed does not care.

What load_dotenv() does and does not do. It searches upward from the current working directory for a .env file, parses it, and inserts any variables that are not already set into os.environ. That last part is deliberate and important: real environment variables win over the file. It means your production platform's injected secrets are never silently overridden by a stray .env that got deployed. If you need the opposite for a specific reason, load_dotenv(override=True) inverts it — but the default is the safe one.

Because it searches from the working directory, running a script from a different folder can fail to find the file. When that matters, be explicit:

from pathlib import Path
from dotenv import load_dotenv

load_dotenv(Path(__file__).parent / ".env")

This anchors the lookup to the script's own location rather than wherever the shell happens to be.

Making Sure .env Never Reaches Git

The .env file only protects you if it stays out of version control. Create or update .gitignore in your project root before your first commit:

.env
.env.*
!.env.example

.venv/
__pycache__/
*.py[cod]

Line by line: .env ignores the main file; .env.* catches variants like .env.local and .env.production; and !.env.example re-includes the one file you do want committed.

The ordering of the last two lines is not optional. Git applies ignore rules in order, and a later rule overrides an earlier one. Put the ! line first and .env.* will re-ignore your example file.

The .env.example Pattern

Commit a placeholder file showing the shape of the configuration without any secret in it:

# .env.example — copy to .env and fill in real values
OPENAI_API_KEY=sk-proj-replace-me
OPENAI_MODEL=gpt-5.6-luna

This solves a problem that only appears when someone else — including future you on a new laptop — clones the project. Without it, the repository gives no indication that OPENAI_API_KEY is required, and the first run fails with an error whose cause is invisible. With it, the setup instruction is one line: copy .env.example to .env and fill in the values.

A critical warning about .gitignore and files already tracked. .gitignore only prevents untracked files from being added. If you committed .env before adding the ignore rule, Git keeps tracking it and every future change is committed too. Fix it with:

git rm --cached .env
git commit -m "Stop tracking .env"

And understand the limit of that fix: it stops future commits, but the key is still in your repository's history, retrievable by anyone with a clone. If the key was ever pushed, rewriting history is not enough either — assume it is compromised and rotate it. Rotation is covered below.

Verifying Your Key Without Exposing It

Before spending money on a real call, confirm the key is loaded. Create check_key.py:

import os
from dotenv import load_dotenv

load_dotenv()

key = os.environ.get("OPENAI_API_KEY")

if not key:
    print("OPENAI_API_KEY is not set.")
    print("Check that .env exists and load_dotenv() runs before this line.")
elif not key.startswith("sk-"):
    print("A value was found but it does not look like an OpenAI key.")
    print("Check for stray quotes or whitespace in .env.")
else:
    print(f"Key loaded: {key[:7]}...{key[-4:]} ({len(key)} characters)")

Several deliberate choices in that script are worth copying into your own code:

  • os.environ.get() rather than os.environ[]. The bracket form raises KeyError on a missing variable; .get() returns None, letting you print a message that explains what to do rather than a traceback that does not.
  • It never prints the whole key. It prints the first seven and last four characters — enough to tell two keys apart, useless to anyone reading over your shoulder or watching a screen share. Make this a reflex: never print() a secret in full, and never log one. Log lines end up in files, monitoring systems, and error trackers.
  • It checks the prefix. The most common .env mistake is quoting or trailing whitespace, and the symptom — a 401 AuthenticationError — looks identical to a genuinely invalid key. Checking the prefix separates "the file is malformed" from "the key is wrong."
  • The length is reported. Two keys that look the same in a truncated view but differ in length are almost always a copy-paste that clipped a character.

What Happens When a Key Leaks

This is not a hypothetical risk, and the timeline is faster than people expect.

Automated scanners continuously crawl public GitHub repositories, Gists, pastebins, npm and PyPI packages, Docker images, and CI logs looking for credential patterns. An OpenAI key has a distinctive, easily-matched prefix. Keys pushed to public repositories have been observed being used within minutes of the push.

What the finder can do with it: run expensive models against your balance until the credit is gone, read whatever your key's permissions allow, and — if the key is unrestricted — use every endpoint your account has access to. There is no per-request approval and no anomaly prompt you get to decline.

GitHub's secret scanning does partner with providers to detect and notify on leaked keys, and OpenAI may revoke a key it learns has been exposed. Do not rely on this. Detection takes time; a bot does not.

Rotating a Compromised Key

If a key has been exposed — pushed to a repository, pasted into a public forum, shown in a screen recording, sent over chat, or logged somewhere you cannot delete — act in this order:

  1. Revoke it first. In the API keys page, delete the key. This is immediate and irreversible, and it stops the bleeding. Do this before you start cleaning up the repository, because cleanup takes minutes and revocation takes seconds.
  2. Create a replacement and update .env and any deployment configuration.
  3. Check your usage page for activity you do not recognise, and note the time window.
  4. Then clean up the source — remove the file from tracking, and rewrite history if you must. But treat this as hygiene, not remediation. Step 1 already removed the risk.

Rotation is cheap. A key is a string; replacing one takes two minutes. There is never a good reason to leave a possibly-exposed key active while you decide whether the exposure was real.

Multiple Keys, Multiple Environments

Real projects end up with several keys, and a naming convention makes them manageable:

Key nameProjectPermissionLives in
laptop-devopenai-courseAll.env on your machine
ci-testsopenai-courseRestrictedCI secret store
prod-apimyapp-productionRestrictedHosting platform secrets

Separate keys per environment give you three things: revoking a leaked development key does not take production down, usage attribution tells you what dev experiments cost versus real traffic, and a compromised CI key cannot touch production data.

For local switching between projects, .env files handle this naturally because each project folder has its own. That is the concrete advantage over a machine-wide shell variable, which can only hold one value at a time.

Keys in Production

A .env file is a development convenience. In production, the file usually should not exist at all — the environment variables are injected by the platform.

  • Managed hosting (Vercel, Railway, Render, Fly.io, Heroku): set environment variables in the project's dashboard. They are injected into the process at start and never written to your repository.
  • Containers: pass with docker run -e OPENAI_API_KEY=..., or better, a secret mount. Never bake a key into a Dockerfile with ENV — image layers are readable by anyone who can pull the image, and the value persists in the layer even if a later layer unsets it.
  • Cloud platforms: use the platform's secret manager (AWS Secrets Manager, Google Secret Manager, Azure Key Vault) and fetch at startup. This adds rotation without redeployment and an audit trail of who read what.
  • Kubernetes: a Secret mounted as an environment variable or file.

The Rule That Matters Most: Never Ship a Key to a Client

Your API key must never reach a user's device. Not in JavaScript served to a browser, not compiled into a mobile app, not in a desktop application's config file.

Why this is absolute: anything delivered to a user's device can be read by that user. Browser code is visible in developer tools. Mobile binaries can be unpacked and strings extracted. Obfuscation delays this by minutes and prevents nothing. A key shipped to clients is a key published to every one of your users, and any one of them can spend your balance.

The correct architecture is a backend proxy:

Browser / mobile app
        │  (your own auth: session token, JWT)
        ▼
Your backend server   ← holds OPENAI_API_KEY
        │
        ▼
   OpenAI API

The client calls your server. Your server authenticates the user with your own auth system, applies your own rate limits and validation, then calls OpenAI with the key that never leaves your infrastructure. Unit 14 builds exactly this with FastAPI.

This structure also gives you things the direct-call approach cannot: per-user quotas, request logging, prompt injection defences, the ability to change models without shipping a client update, and a single place to add caching.

Common Mistakes

Hardcoding the key in source. The origin of most leaks. Why it happens: it is the fastest thing that works while you are experimenting. Prevention: use .env from your very first script, so there is no "temporary" version to forget about.

Adding .gitignore after the first commit. The file is already tracked and the ignore rule does nothing. The fix: git rm --cached .env, then rotate the key if the commit was ever pushed. Prevention: create .gitignore before git init finishes being useful — it is the first file in a new project, not an afterthought.

Calling load_dotenv() after creating the client. The client already read the environment and already failed. The tell: an OpenAIError about a missing key while .env visibly contains one. Prevention: put load_dotenv() immediately after the imports, at the top of the entry-point file.

Quotes or spaces in .env. OPENAI_API_KEY = "sk-proj-..." can produce a value containing quote characters or leading whitespace, and the resulting 401 is indistinguishable from a bad key. Prevention: KEY=value, no spaces, no quotes, and use the prefix check in check_key.py to catch it.

Printing or logging the key. print(f"Using key {key}") while debugging, then the line stays and the key ends up in production logs, a shipped stack trace, or a support ticket. Prevention: truncate at the point of printing, always.

Committing .env to a private repository and assuming that is safe. Private repositories become public, get forked, get cloned onto laptops, and get shared with contractors. The key is also in every clone's history forever. Private is not a security control for secrets.

Sharing a single key across everything. One key for local dev, CI and production means one leak revokes everything and no usage attribution at all. Separate keys cost nothing to create.

Pasting the key into an AI assistant, a bug report, or a screenshot. Screen recordings of terminals are a genuinely common leak vector, because export OPENAI_API_KEY=... scrolls past in the recording. Treat any key that has appeared on a screen you shared as exposed.

Best Practices Summary

Create a dedicated project for this course, with a spend limit. It bounds the cost of any mistake to a number you chose.

One key per environment, named descriptively. laptop-dev, ci-tests, prod-api — not key1, key2, new key.

.env locally, platform secrets in production. The .env file is a development convenience, not a deployment mechanism.

.gitignore before the first commit, with .env.example committed alongside. One file protects the secret; the other documents that it exists.

Never print, log, or hardcode a key. Truncate when you must display it.

Rotate first, investigate second. Revocation takes seconds and is free. Deliberating takes minutes, and the bots do not wait.

Never let a key reach a client device. Put a server in between — always.

A Configuration Module You Can Reuse

Scattering os.environ.get("OPENAI_API_KEY") and load_dotenv() through a growing project causes two problems: the load order becomes accidental, and a missing key is discovered at the moment of the first API call rather than at startup.

A single config.py fixes both:

import os
from pathlib import Path
from dotenv import load_dotenv

load_dotenv(Path(__file__).parent / ".env")

OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")

if not OPENAI_API_KEY:
    raise RuntimeError(
        "OPENAI_API_KEY is not set. Copy .env.example to .env and add your key."
    )

Then everywhere else:

from openai import OpenAI
import config

client = OpenAI(api_key=config.OPENAI_API_KEY)

Four things this buys you:

  • Deterministic load order. load_dotenv() runs exactly once, when config is first imported, and every module that imports config gets the loaded environment. No file can accidentally run before it.
  • Fail fast with a useful message. A missing key stops the program at startup with an instruction, not fifty lines into a script with a library exception. This is the difference between a thirty-second fix and a confused search.
  • A default model in one place. OPENAI_MODEL with a fallback means switching models across the whole project is one environment variable, and Lesson 1's warning about hardcoded model names is handled structurally.
  • Path-anchored .env lookup. Using Path(__file__).parent means the file is found regardless of which directory you run the script from.

For larger applications, pydantic-settings formalises this pattern with type validation and typed defaults:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    openai_api_key: str
    openai_model: str = "gpt-5.6-luna"

    class Config:
        env_file = ".env"

settings = Settings()

Here the missing-key check is implicit: openai_api_key: str has no default, so constructing Settings() without it raises a validation error naming the exact field. It is the same fail-fast behaviour, expressed as a type.

Anatomy of an Authentication Failure

When a request is rejected, the status code narrows the cause quickly:

ErrorStatusWhat it meansWhere to look
AuthenticationError401Key missing, malformed, revoked, or wrong.env contents, stray quotes, whether the key was deleted
PermissionDeniedError403Key is valid but not allowed to do thisKey permission level; whether the model is enabled for the project
RateLimitError429Too many requests, or no credit remainingUsage page; the error message distinguishes the two
BadRequestError400Request body is wrong — bad model name, invalid parameterThe parameters you sent; not a key problem at all

Two of these are regularly misdiagnosed.

A 401 with a key that "definitely works" is nearly always a loading problem rather than a key problem — a .env in the wrong directory, load_dotenv() running too late, a quoted value, or a shell variable from an old session shadowing the file (remember that real environment variables win over .env by default). Run the check_key.py script above; if it prints a plausible prefix and length, the key is loaded and the problem is genuinely the key itself.

A 429 that appears immediately, on the first request of the day, is not a rate limit — it is an exhausted credit balance, which the API reports through the same status code with a credit_balance_exhausted billing error. Adding retries makes this worse, not better: you burn your retry budget on a condition that cannot resolve itself. Lesson 5 covers the billing side, and Unit 12 covers distinguishing retryable from non-retryable failures properly.

A 403 on a model name that exists usually means the model is not enabled for your project, or your key is restricted away from the endpoint. Check the project's model settings before assuming the key is broken.

Sharing Access Within a Team

The instinct when a colleague needs access is to send them your key. Do not. A shared key means usage cannot be attributed to a person, revoking it disrupts everyone, and the secret now exists in a chat history you do not control.

Two better options exist:

  • Invite them to the project. Each member generates their own key from their own account. Usage is attributable per person, and removing someone from the project removes their access without touching anyone else's key.
  • Use a service account for machines. Automated systems — CI pipelines, deployed services, scheduled jobs — should have keys that belong to a service account rather than to an individual. A key tied to a person breaks when that person leaves or rotates their credentials, and it attributes a production bill to someone who did not incur it. Note that only organisation or project owners can create service account keys.

If you must transfer a key to someone, never send it over email or chat. Use a secret-sharing tool that expires the link after a single view, and rotate the key afterwards if there is any doubt about where the message ended up.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Ask about this post
AI Ask about this post

Ask questions about Creating an API Key and get answers drawn from it.

Signed-in readers only.