Section D · Trust, limits & speed

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:

CallerCredentialActs as
A team's own serversAPI key, sk_live_… or sk_test_…The team
A third-party appOAuth 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_ and sk_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_at per 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"):

  1. The app makes a secret. It generates a random code_verifier and computes code_challenge = BASE64URL(SHA256(code_verifier)).
  2. 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
  3. The user signs in and consents to the listed scopes. Snip shows them in plain words: "Read link analytics".
  4. Snip redirects back to the registered redirect_uri with a short-lived, single-use code and the same state. The app checks that state matches what it sent, which blocks forged callbacks.
  5. 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
  6. 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.

ScopeAllowsExample endpoints
links:readList and fetch linksGET /v1/links, GET /v1/links/{code}
links:writeCreate, update and delete linksPOST /v1/links, PATCH /v1/links/{code}, DELETE /v1/links/{code}
analytics:readRead click statisticsGET /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:write and 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.

OpaqueJWT
VerifyLookup or introspectionSignature check, no network
Revoke before expiryImmediateHard; wait for expiry or keep a deny-list
Contents visible to clientNoYes (signed, not encrypted)
FitsOne API, fast revocation, API keysMany 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.

  1. 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.
  2. Credentials in logs. Fix: redact the Authorization header in request logging by default, and never log query strings that could carry secrets.
  3. No rotation path. Fix: two active keys per team and a documented rotation step. A key nobody can replace without downtime lives forever.
  4. No expiry. Fix: access tokens in minutes to an hour, refresh tokens rotated, and an idle timeout on grants nobody has used in months.
  5. 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.
  6. Missing TLS. Fix: HTTPS only, HSTS on the hostname, and no HTTP listener that redirects.
  7. 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_uri against 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 an sk_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:read only. 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.

  1. 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.

  2. An attacker copies the authorization code from 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 the code_challenge sent 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.

  3. A token with only analytics:read sends DELETE /v1/links/42. Which status code do you return, and what would a missing Authorization header get?

    Show answer

    403: the caller is known but not permitted, so the body should name links:write as the missing scope. A missing or invalid credential gets 401 with a WWW-Authenticate header. See Scopes and least privilege and Errors & Validation.

  4. 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.

  5. 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.

  6. 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 Authorization header 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