Authentication & Scopes
Two different questions hide in one word: who is calling, and what may they do? This chapter is API keys, OAuth 2.0 with PKCE for third-party apps, scopes that bound a token, and the secrets you must never leak.
Two questions
"Auth" names two separate checks. Authentication asks who is calling. Authorization asks what that caller may do. A valid key answers the first. It says nothing about whether the caller may delete a link, read another team's clicks, or change billing.
Keep the two apart in code. Authentication runs once, at the edge of the request, and produces a principal: a team, or a user acting through an app, plus a set of granted scopes. Authorization runs per endpoint and per object, against that principal. Most serious API breaches are not stolen passwords. They are authorization gaps: a caller who is properly authenticated reads an object that belongs to someone else. OWASP lists broken object-level authorization first for this reason.
Snip's API is the control plane: it creates and manages links. The redirect at snip.ly/abc123 is the data plane, and it needs no credential at all. Everything below applies to https://api.snip.ly/v1 only.
Snip supports two credential types, and each fits one situation:
| Caller | Credential | Acts as |
|---|---|---|
| A team's own servers | API key, sk_live_… or sk_test_… | The team |
| A third-party app | OAuth 2.0 access token (authorization code + PKCE) | A user, limited to the scopes they granted |
API keys
An API key is a long random string that stands for a team. It is the right model when the caller and the owner are the same party: a team's backend creating links, a cron job pulling reports. There is no user to ask for consent, so a protocol that asks for it adds nothing.
POST /v1/links HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_9fT3k…
Content-Type: application/json
{"url": "https://example.com/spring-sale"}
Design the key, not just the check
- Prefix it.
sk_live_andsk_test_tell a human which environment a key opens. They also let secret scanners (GitHub's, your own pre-commit hook) recognise a leaked Snip key by pattern and alert, or revoke it, within minutes. A bare 32-byte hex string is invisible to a scanner. - Show it once. Display the full key at creation and never again. Afterwards show only the last four characters.
- Store a hash. Treat keys like passwords. Keep a SHA-256 of the key, not the key. Keys are high-entropy random values, so a fast hash is enough here; a slow password hash such as bcrypt would only add latency to every request. A database leak then does not hand over working credentials.
- Support two active keys per team. Rotation without downtime needs overlap: create key B, deploy it, confirm traffic has moved (log the key's last four characters or its id, never the key), then revoke key A. With a one-key limit, every rotation is an outage, so nobody rotates.
- Make revocation instant and cheap. One call, effective on the next request. Record
last_used_atper key so teams can find forgotten ones.
Never put a secret key in client code or a URL. A key in a browser bundle, a mobile app or a ?api_key= parameter is public. Anyone can read a bundle, decompile an app, or find the URL in a proxy log, a browser history or a Referer header. A sk_live_ key lives on a server and nowhere else. If a browser must call Snip, put your own backend between them, or use OAuth so the token is scoped and short-lived.
OAuth 2.0 with PKCE
A partner builds a dashboard for Snip users. Asking those users to paste their team's sk_live_ key into a third party hands over everything, forever, with no way to see or limit what the partner does. OAuth 2.0 fixes this with delegation: the user approves a narrow set of scopes, and the app receives a token that carries exactly that.
The flow, step by step
The authorization code flow with PKCE (Proof Key for Code Exchange, pronounced "pixie"):
- The app makes a secret. It generates a random
code_verifierand computescode_challenge = BASE64URL(SHA256(code_verifier)). - The app sends the user to Snip with the challenge, never the verifier.
GET https://snip.ly/oauth/authorize ?response_type=code &client_id=app_7Hq2 &redirect_uri=https%3A%2F%2Fdash.example.com%2Fcallback &scope=analytics%3Aread &state=xyz123 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 - The user signs in and consents to the listed scopes. Snip shows them in plain words: "Read link analytics".
- Snip redirects back to the registered
redirect_uriwith a short-lived, single-usecodeand the samestate. The app checks thatstatematches what it sent, which blocks forged callbacks. - The app exchanges the code on the back channel, and now proves it started the flow by sending the verifier:
POST /oauth/token HTTP/1.1 Host: api.snip.ly Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https%3A%2F%2Fdash.example.com%2Fcallback &client_id=app_7Hq2 &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk - Snip answers with tokens. It hashes the verifier and compares it with the stored challenge. Only a match gets tokens.
{ "access_token": "snip_at_2YotnFZFEjr1zCsicMWpAA", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "snip_rt_tGzv3JOkF0XG5Qx2TlKWIA", "scope": "analytics:read" }
Why PKCE
The code travels through the browser, where a malicious app, a browser extension or a logged redirect can copy it. Without PKCE, whoever holds the code can swap it for tokens. With PKCE the thief has the code but not the verifier, which never left the app, so the exchange fails. The original spec required PKCE only for public clients such as mobile and single-page apps. The current security guidance recommends it for all clients, including server-side ones, because it costs almost nothing. Snip requires it for every app.
Why not the implicit flow
The implicit flow returned the access token directly in the redirect URL fragment. That puts a bearer token into browser history, logs and any script on the page, with no way to bind it to the app that asked. The security guidance now says not to use it. If you meet it in an older tutorial, skip it.
Access tokens, refresh tokens
Keep access tokens short-lived: Snip uses one hour. A leaked access token then expires on its own. The refresh token is long-lived and lets the app get a new access token without bothering the user. It is more valuable, so protect it more: store it server-side, and rotate it on every use. Each refresh returns a new refresh token and invalidates the old one. If an old one is ever replayed, someone has copied it, and the server revokes the whole grant.
Two companion endpoints round this out. Revocation (RFC 7009) lets an app, or the user from their settings page, kill a token. Introspection (RFC 7662) lets a resource server ask the authorization server whether a token is active and what it allows.
Scopes and least privilege
A scope is a named permission. The token carries the scopes the user granted, and the API checks them on every request. The principle is least privilege: a credential gets the smallest set that does the job. A dashboard that charts clicks needs analytics:read. It does not need to create or delete links, so it must not be able to.
| Scope | Allows | Example endpoints |
|---|---|---|
links:read | List and fetch links | GET /v1/links, GET /v1/links/{code} |
links:write | Create, update and delete links | POST /v1/links, PATCH /v1/links/{code}, DELETE /v1/links/{code} |
analytics:read | Read click statistics | GET /v1/links/{code}/clicks |
Three rules keep scopes useful:
- Enforce per endpoint, on the server. A scope list in the consent screen means nothing if the handler never checks it. Put the check in middleware that maps each route to the scope it needs, so a new endpoint without a mapping fails closed.
- Scopes narrow; they never widen. A token's effective permission is the intersection of its scopes and what the user may do. A team member who cannot delete links cannot grant an app
links:writeand make it so. - Split by risk, not by endpoint. Read and write are different risks, so they are different scopes. A scope per route is unusable: no one will review thirty checkboxes. Three scopes are enough to start; add one only when a real caller needs the split.
A request outside the token's scope gets 403 Forbidden, with a problem body that names the missing scope. A missing or invalid credential gets 401 instead. The line between them is the subject of Errors & Validation: 401 means "I do not know who you are", 403 means "I know, and the answer is no".
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://api.snip.ly/errors/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This token has analytics:read. DELETE /v1/links/{code} needs links:write.",
"required_scope": "links:write"
}
Scopes bound the kind of action. They do not bound which objects. A token with links:read must still see only the links of the team that granted it. Check object ownership on every lookup, even when the scope check passed.
Where credentials go
Send the credential in the Authorization header, using the Bearer scheme from RFC 6750, on every request. Snip uses the same header for API keys and OAuth tokens, so client code does not branch on credential type.
Do not accept credentials in the query string. URLs are logged by proxies, load balancers and CDNs, stored in browser history, copied into bug reports, and sent onward in Referer headers. Headers are not logged by default. RFC 6750 allows a query-string token but warns against it. Do not offer it.
Require TLS for every call and refuse plain HTTP outright. Do not redirect it: a client that sent Authorization over HTTP has already leaked the credential, and a redirect hides the mistake. Bearer means exactly this: whoever bears the token can use it. TLS is the only thing that keeps a passive observer from becoming the caller.
Return WWW-Authenticate: Bearer error="invalid_token" with a 401, and never echo a submitted credential back in an error body or a log line. Log the key id or last four characters instead.
Opaque tokens vs JWTs
An access token is just a string the API must be able to trust. There are two ways to make that work, and the trade is real.
An opaque token is a random string with no meaning. The server looks it up in a database or cache (or asks the authorization server by introspection) to find its owner, scopes and expiry. Revocation is trivial: delete the row, and the next request fails. The cost is a lookup per request.
A JWT (JSON Web Token, RFC 7519) carries its claims inside: subject, scopes, expiry, signed by the issuer. Any service holding the public key can verify it with no network call. That is fast and stateless, and it suits many services that all need to check the same token. The cost is revocation. The token stays valid until it expires, because nothing is looking anything up. You can bolt on a deny-list, but that restores the lookup you removed.
| Opaque | JWT | |
|---|---|---|
| Verify | Lookup or introspection | Signature check, no network |
| Revoke before expiry | Immediate | Hard; wait for expiry or keep a deny-list |
| Contents visible to client | No | Yes (signed, not encrypted) |
| Fits | One API, fast revocation, API keys | Many services, short lifetimes |
My default: opaque tokens and API keys for a single API like Snip's, where one cached lookup is cheap and instant revocation matters. Reach for JWTs when many internal services must verify the same token without calling home, and keep them short, five to fifteen minutes. Whichever you choose, verify the signature and the algorithm you expect, check exp, iss and aud, and never put secrets in the payload. Anyone can decode it.
Common failures
Most auth incidents come from a short list. Check yours against it.
- Keys in git or client bundles. Fix: a prefix so scanners find them, a pre-commit secret scan, and fast revocation. Assume any key that touched a repo is burned.
- Credentials in logs. Fix: redact the
Authorizationheader in request logging by default, and never log query strings that could carry secrets. - No rotation path. Fix: two active keys per team and a documented rotation step. A key nobody can replace without downtime lives forever.
- No expiry. Fix: access tokens in minutes to an hour, refresh tokens rotated, and an idle timeout on grants nobody has used in months.
- Overbroad scopes. Fix: default to the narrowest scope, show users what they grant in plain words, and let API keys carry scopes too, so a reporting job gets a read-only key.
- Missing TLS. Fix: HTTPS only, HSTS on the hostname, and no HTTP listener that redirects.
- Confused deputy. The API acts with its own broad authority on behalf of a caller who has less. Fix: always authorise against the caller's principal and scopes, never the service's. Validate
redirect_uriagainst an exact registered value, so an attacker cannot send codes to their own endpoint.
Prompt-injected or buggy AI agents calling your API are a fresh instance of the confused deputy. Give an agent its own narrow credential. Do not lend it a developer's key.
Snip, end to end
Three callers, three credentials:
- The marketing team's script creates a link for each campaign email. It runs on the team's server and uses an
sk_live_key, held in a secret manager and rotated each quarter with two keys overlapping. Staging uses ansk_test_key that cannot touch production data. - A partner's analytics dashboard shows click charts to Snip users. It uses OAuth with PKCE and asks for
analytics:readonly. If the partner is breached, the attacker can read click counts and nothing else. No link can be changed or deleted. - A user-facing integration, such as a newsletter tool that shortens every URL in a draft, asks for
links:write. Its consent screen says so in plain words, and the user can revoke it from their Snip settings.
One more mechanism looks like auth but is not request auth. Snip's webhooks flow the other way: Snip calls your server. Your server cannot present a bearer token to Snip's callback, so Snip signs each delivery with HMAC-SHA256 in a Snip-Signature header and you verify it with a shared secret. That proves the message came from Snip, and it is a separate secret from any API key. Async, Webhooks & Streaming covers the verification step by step.
After auth, the next question is how much a valid caller may do in a given minute. That is Rate Limits & Quotas.
✓ Check yourself
Answer from the mechanism, then open the answer.
A teammate proposes letting the mobile app call Snip directly with the team's
sk_live_key, to save building a backend endpoint. What goes wrong, and what are two better options?Show answer
Anyone can extract the key from the app binary or its network traffic, and it grants the whole team's access with no way to narrow it. Better: route calls through your own backend that holds the key, or use OAuth with PKCE so the app holds a scoped, short-lived token for the signed-in user. See API keys.
An attacker copies the authorization
codefrom a redirect URL. Why can they not turn it into tokens when PKCE is in use?Show answer
The token endpoint requires the
code_verifier, whose hash matches thecode_challengesent at the start. The verifier never travelled through the browser, so the attacker has the code but cannot prove they began the flow. See OAuth 2.0 with PKCE.A token with only
analytics:readsendsDELETE /v1/links/42. Which status code do you return, and what would a missingAuthorizationheader get?Show answer
403: the caller is known but not permitted, so the body should name
links:writeas the missing scope. A missing or invalid credential gets 401 with aWWW-Authenticateheader. See Scopes and least privilege and Errors & Validation.Your security team wants to revoke a compromised access token within seconds. Why does that favour opaque tokens over long-lived JWTs, and what could you do if you must use JWTs?
Show answer
A JWT is verified from its signature alone, so it works until
exp; an opaque token is looked up, so deleting the record ends it at once. With JWTs, keep lifetimes to minutes and add a deny-list checked on each request, which gives back some of the lookup cost. See Opaque tokens vs JWTs.A user with read-only rights on a team approves an app asking for
links:write. What should the resulting token be able to do, and why?Show answer
Nothing beyond reads. Effective permission is the intersection of the granted scopes and the user's own rights, so delegation cannot widen access; otherwise the API becomes a confused deputy. See Scopes and least privilege and Common failures.
Why does Snip support two active API keys per team, and what would you see in practice if it allowed only one?
Show answer
Rotation needs overlap: deploy the new key while the old one still works, then revoke the old one. With one key, each rotation causes an outage, so teams skip rotation and old keys live on. See API keys.
Takeaway
- Authentication says who is calling; authorization says what they may do. Check both, and check object ownership as well as scope.
- Use API keys for a team's own servers and OAuth 2.0 with PKCE for apps that act for a user. Never use the implicit flow.
- Prefix keys, show them once, store only a hash, and allow two active keys so rotation causes no outage.
- Grant the narrowest scope that works. A missing scope is a 403; a missing or invalid credential is a 401.
- Credentials go in the
Authorizationheader over TLS, never in a URL, a client bundle or a log. - Opaque tokens revoke fast; JWTs verify fast. For a single API, start opaque.
Next, how much a valid caller may do per minute. Rate Limits & Quotas →
Further reading
- RFC 6749: The OAuth 2.0 Authorization Framework — the core spec: roles, grants, tokens, scopes.
- RFC 7636: Proof Key for Code Exchange (PKCE) — the verifier and challenge mechanism, with the attack it stops.
- RFC 9700: Best Current Practice for OAuth 2.0 Security — why PKCE for all clients, why not implicit, refresh token rotation.
- RFC 6750: OAuth 2.0 Bearer Token Usage — the
Authorization: Bearerheader and why to avoid the query string. - RFC 7519: JSON Web Token (JWT) — the claim set and validation rules.
- RFC 7009: OAuth 2.0 Token Revocation and RFC 7662: Token Introspection — the two companion endpoints.
- OWASP API Security Top 10 (2023) — the common authorization and authentication failures, ranked.