Ruby on Rails Module 9 – APIs with Rails
API authentication, CORS and rate limits
Protect a Rails 8.1 API with bearer tokens, an explicit rack-cors allowlist and a per-token rate limit that answers 429 with Retry-After.
What you will learn
- Configure rack-cors with an explicit list of origins, methods and headers
- Protect every API action with a bearer token and the 401 challenge of RFC 6750
- Store API tokens as digests with scopes, and revoke a token at once
- Limit payments per token with rate_limit and make a retried payment run once
Before you start
What you’ll learn · Before you start
What you will learn
- Configure rack-cors with an explicit list of origins, methods and headers
- Protect every API action with a bearer token and the 401 challenge of RFC 6750
- Store API tokens as digests with scopes, and revoke a token at once
- Limit payments per token with rate_limit and make a retried payment run once
Before you start
On this page
The batches API of the previous lesson answers anyone who asks, and two front ends are about to call it: the
guardians’ mobile app and the staff dashboard at https://admin.kaksha.example.com. Before they ship it needs three
guards: a CORS policy that tells browsers which sites’ scripts may read its answers, a bearer token that names the
calling client, and a rate limit: A filter that counts the requests of one client against a key in a cache store and refuses them once the count rises above the number allowed in the window, by default with a 429 response. More in “Filters, errors and HTTP features” that stops a looping client from sending 50 payments a second.
A CORS policy lists the origins a browser may trust
A browser lets a page’s script read a response from another origin (scheme, host and port) only when the response
names the page’s origin in Access-Control-Allow-Origin, or * for a request sent without credentials. That rule is CORS: Cross-Origin Resource Sharing: a protocol where the browser sends an Origin header and the server returns Access-Control-Allow-Origin to say which origins may read the response.. A request with an
Authorization header gets a preflight request: An OPTIONS request a browser sends before a cross-origin request whose method or headers are not on the CORS safelist, such as one with an Authorization header; the answer says whether the real request may follow. first: an OPTIONS request that names the
method and headers the script wants to use.
In Rails the rack-cors gem answers both. A rails new --api app has the gem commented out in its Gemfile and a
commented example in config/initializers/cors.rb; this API turns both on with an allowlist: A rule that accepts only values on a known-good list and refuses everything else, rather than trying to list the values to reject, which always misses one. More in “Validations, errors and normalization” of two origins:
# Cross-origin requests from the two Kaksha front ends, and from no other origin. rack-cors compares the request's
# Origin header with each entry as a whole: scheme, host and port. An origin that matches no entry gets no CORS
# headers at all, so the browser keeps the response away from that page's script.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "https://kaksha.example.com", "https://admin.kaksha.example.com"
resource "/api/*",
headers: %w[Authorization Content-Type Idempotency-Key],
methods: %i[get post options head],
expose: %w[Retry-After],
max_age: 600
end
end The session sends a preflight and a GET with a valid token from the dashboard, and the same two from
https://unknown.example:
# A CORS preflight and a real request from the allowed dashboard origin and from an origin the policy does not list.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha_api
cp -R ../lab/. .
bin/rails db:migrate > /dev/null
bin/rails test test/integration/cors_test.rb --seed 1
SH Output
$ cd ../kaksha_api
$ cp -R ../lab/. .
$ bin/rails db:migrate > /dev/null
$ bin/rails test test/integration/cors_test.rb --seed …
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed …
# Running:
# preflight from https://admin.kaksha.example.com -> 200
access-control-allow-origin: https://admin.kaksha.example.com
access-control-allow-methods: GET, POST, OPTIONS, HEAD
access-control-expose-headers: Retry-After
access-control-max-age: 600
access-control-allow-headers: authorization
# preflight from https://unknown.example -> 200, CORS headers: 0
# GET from https://admin.kaksha.example.com -> 200
access-control-allow-origin: https://admin.kaksha.example.com
access-control-allow-methods: GET, POST, OPTIONS, HEAD
access-control-expose-headers: Retry-After
access-control-max-age: 600
vary: Origin
# GET from https://unknown.example -> 200, body: {"data":[],"next_cursor":null}
vary: Origin
.
Finished in …s, … runs/s, … assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby cors_session.rb
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The browser asks first. A script on https://admin.kaksha.example.com wants GET /api/v1/batches with an Authorization header. That header makes the request one a browser checks first, so it sends an OPTIONS preflight naming the method and the header.
- Rack::Cors answers it. The origin is on the list, so Rack::Cors answers 200 itself with the allowed origin, methods, request headers and a max age of 600 seconds. The preflight never reaches a controller.
- The real GET follows. The browser now sends the GET with its token. Rack::Cors passes it on, and the controller checks the token as it would for any client.
- The response carries the grant. The 200 comes back with access-control-allow-origin naming the dashboard and vary: Origin, so a cache keeps one copy per origin. The browser hands the body to the script.
- An unlisted origin asks. A page on https://unknown.example sends the same preflight. Rack::Cors answers 200 with no CORS headers at all, and the browser does not send the real request.
- The server still answers others. A client that skips the browser's check, such as curl, gets the full 200 body from the same API. CORS decides what a browser lets a page read; the token check decides who gets data.
The dashboard’s preflight got 200 with its origin, the methods, the authorization header and a max age of 600
seconds. The unknown origin’s preflight got 200 with no CORS headers, so a browser stops there. Its direct GET still
got the 200 body, with only vary: Origin: CORS decides what a browser lets a page read, never who may call the API.
A list that allows everyone
origins { |source, _env| true } reads like a list and allows every origin. With credentials: true and cookies that
browsers send cross-site, it lets any site’s script read whatever those cookies give access to. Name each origin, and turn credentials on only for an endpoint
that really uses cookies, which then needs CSRF protection back too.
Checkpoint
Check: CORS and rack-cors
A bearer token in the Authorization header names the client
A bearer token: A credential presented in the Authorization header as Bearer <token>; whoever holds the string can call the API as the identity the token represents, so its secrecy is the only protection. works for whoever holds it: the client sends Authorization: Bearer <token> with each request, and RFC 6750 says how a server refuses one. ActionController::API leaves the token
helpers out, so the API’s concern includes them and checks the header before every action. API authentication here
means one token per client app, checked on every request:
# Bearer-token authentication for the API controllers (RFC 6750). ActionController::API does not include the
# token helpers, so this concern includes them.
module ApiTokenAuthentication
extend ActiveSupport::Concern
include ActionController::HttpAuthentication::Token::ControllerMethods
included do
before_action :authenticate_api_token!
end
private
attr_reader :current_token
def authenticate_api_token!
@current_token = authenticate_with_http_token { |plain, _options| ApiToken.authenticate(plain) }
return if @current_token
# A token that was sent and failed gets error="invalid_token"; no token, or another scheme such as Basic,
# gets the bare challenge (RFC 6750 section 3.1).
tried = request.authorization.to_s.match?(/\A(?:Bearer|Token)\s/)
response.headers["WWW-Authenticate"] = %(Bearer realm="kaksha"#{', error="invalid_token"' if tried})
render_problem(status: 401, title: "Unauthorized",
detail: tried ? "The token is unknown or revoked." : "Send Authorization: Bearer <token>.")
end
end # Bearer tokens on /api/v1/batches: no header, a token the API never issued, and a valid token in two schemes.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha_api
cp -R ../lab/. .
bin/rails db:migrate > /dev/null
bin/rails test test/integration/bearer_test.rb --seed 1
SH Output
$ cd ../kaksha_api $ cp -R ../lab/. . $ bin/rails db:migrate > /dev/null $ bin/rails test test/integration/bearer_test.rb --seed … Running 1 tests in a single process (parallelization threshold is 50) Run options: --seed … # Running: # no Authorization header -> 401 www-authenticate: Bearer realm="kaksha" # a token the API never issued -> 401 www-authenticate: Bearer realm="kaksha", error="invalid_token" # Authorization: Bearer <the issued token> -> 200 # Authorization: Token <the issued token> -> 200 . Finished in …s, … runs/s, … assertions/s. 1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby bearer_session.rb
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- No Authorization header. The client sends nothing. The API answers 401 with WWW-Authenticate: Bearer realm="kaksha", which tells the client which scheme to use and nothing more.
- A token nobody issued. The header carries a token the API never issued. The answer is 401 again, now with error="invalid_token", because the client did try to authenticate.
- A valid bearer token. The issued token passes the check, and the action runs and answers 200.
- The same token as Token. Rails' token helper reads both Authorization: Bearer and Authorization: Token, so the same value sent with the older Token scheme also answers 200.
No header got 401 with the bare challenge Bearer realm="kaksha"; a made-up token got error="invalid_token" too, as
RFC 6750 asks once a client has tried. The issued token answered 200 in the Bearer and the older Token scheme alike.
The table stores only a SHA-256 token digest: A one-way hash of a token, usually SHA-256, stored in the database instead of the plain token so a leaked table does not hand the attacker every credential. of each token, never the token. Each token
carries token scope: A list of permissions a token carries, usually stored as a space-separated string; the controller checks the scope before acting, so a read-only token cannot create resources., and one without the scope an action needs gets 403. A revoked token fails on its next request.
A rate limit answers 429 when one client sends too much
Rails 8.1’s rate_limit is a class method that adds a before_action, so rate limits are one line per controller. The payments endpoint counts per token, not per IP
address, because many guardians share one school network:
# The limit line of app/controllers/api/v1/payments_controller.rb: five payments a minute, counted per token.
class Api::V1::PaymentsController < Api::V1::BaseController
rate_limit to: 5, within: 1.minute, by: -> { current_token.id }, with: -> { too_many_requests }, only: :create
end The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The first payment. Before the action runs, rate_limit adds 1 to a counter in the cache store under a key built from the controller path and the token's id, and the counter expires one minute after it starts.
- Payments 2 to 5. Each later payment in the same minute adds 1. Up to 5 the action runs and answers 201.
- The sixth payment. The sixth increment returns 6, which is more than 5, so rate_limit calls its with: handler instead of the action, and the client gets 429 Too Many Requests.
- A new minute. When the counter expires, the next payment starts a new count at 1. Another token has its own counter all along.
The sixth payment in a minute meets the with: handler instead of the action and gets 429 Too Many Requests, the
status RFC 6585 defines. The limit runs before the action, so a refused payment never reaches the database. The counter lives in the controller’s cache store, so a store that keeps nothing limits
nothing. A 429 may carry Retry-After: A response header, defined in RFC 9110, that tells a client how long to wait before its next request, in seconds or as a date; a 429 or a 503 answer may carry it., the seconds to wait. An
Idempotency-Key: A request header in which a client names one attempt of a request that must not run twice, such as a payment, so the server can run it once and answer retries with the stored response. header lets a client retry a payment without paying twice.
Checkpoint
Check: Bearer tokens and the 401 challenge · Rate limits and 429
Practise: change the policy and predict the answers
Work on your own copy of the lab and of the CORS and bearer tests, and write down what you expect before each run.
- Add
http://localhost:5173tooriginsincors.rbfor a front-end developer’s machine, add a preflight from that origin tocors_test.rb, and predict its headers. - In
bearer_test.rb, sendAuthorization: Basic a2Frc2hhOmxvZ2luand predict the status and the challenge.
Practice that adapts to you
Practise this lesson
7 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Hashed tokens, scopes and revocation, run end to end Pro: opens with a Pro, Premium or Ultimate pass
A bearer token is only as safe as the table behind it: what the table stores, what each token may do, and how fast a lost one stops working.
In this part:
- Only a digest of each token reaches the database
- Why a lookup by digest leaks nothing useful, and where a constant-time compare belongs
- Scopes turn a valid token into a 403
- Revocation stops a token on its next request
- A table of tokens, a JWT or an OAuth server
1 diagram · 3 runnable examples (Ruby) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Limits that count, a 429 to act on, and payments that run once Pro: opens with a Pro, Premium or Ultimate pass
A limit is only as good as the counter behind it, and a client that retries after a timeout pays twice unless the server remembers the first request.
In this part:
- A limit counts only in a store that keeps counts
- A 429 the client can act on
- An Idempotency-Key makes a retried payment run once
1 diagram · 4 runnable examples (Ruby) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Warm-up: why should rack-cors list each origin instead of echoing the request’s Origin header?
An echoed origin allows every site while looking like a policy. A browser trusts whatever the response names, so any page’s script could read the API’s answers, and with cookies allowed it could read what the signed-in user can see. A list of the API’s own front ends lets only them read cross-origin, and CORS never replaces authentication: other clients reach the API anyway.
Four harder questions on tokens, origins and limits, with first steps and follow-ups in the module’s bank:
-
Mid to senior
An API that serves signed-in users' data echoes the Origin header into Access-Control-Allow-Origin and allows credentials. Walk through what a page on another site can do, say which users are exposed, and give the fix.
-
Mid to senior
A backup of the api_tokens table leaks. The table stores a SHA-256 digest of each token. What can someone holding the backup do, and what should the team do next?
-
Mid to senior
A rate limit of 10 requests a minute refuses the eleventh request in development, but its test lets all eleven through. The controller code is the same everywhere. Find the cause and fix the test without slowing the whole suite.
-
Senior to staff
A team plans to replace its database tokens with JWTs. Explain what happens to revocation, and design a way to stop a stolen JWT within one minute of an incident.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does a CORS policy control, and what does it not stop?
Which origins' scripts a browser lets read the API's answers. A client that is not a browser still gets the body: CORS never decides who may call the API, so authentication is still needed.
-
Why must a CORS policy name each origin instead of allowing whatever origin asks?
An origin check that accepts everything allows every site while looking like a policy. With credentials on and cookies sent cross-site, any site's script could read what the signed-in user can see. Turn credentials on only for an endpoint that really uses cookies, and give it CSRF protection back.
-
Which request gets a CORS preflight, and how did an unknown origin's preflight come back?
A cross-origin request with an Authorization header is preceded by an OPTIONS request naming the method and headers the script wants. The unknown origin got 200 with no CORS headers, so the browser went no further.
-
How does an API answer a request with no bearer token, and one with a made-up token?
Both get 401. Without a token the answer is a bare challenge naming the realm; once a client has sent a token, the challenge adds error="invalid_token", as RFC 6750 asks. ActionController::API leaves out the token helpers, so the API's concern includes them.
-
What does the token table store for each client, and what do scopes and revocation do?
Only a SHA-256 digest of each token, never the token itself. A token without the scope an action needs gets 403, and a revoked token fails on its next request.
-
Why does the payments endpoint count its rate limit per token instead of per IP address, and what does a refused call get?
Many guardians share one school network, so one address can hold many honest clients. The call over the limit gets 429 Too Many Requests before the action runs, so it never reaches the database; the response may carry Retry-After.
Key takeaways
- rack-cors lets a browser hand cross-origin answers only to the origins it lists, answers preflights itself, and never stops a client that is not a browser.
- API authentication with
authenticate_with_http_tokenreadsAuthorization: Bearer, and RFC 6750 gives the refusals: a bare challenge without a token,invalid_tokenfor a bad one. - API tokens are stored as digests, carry scopes and can be revoked at once.
- Rate limits with
rate_limitcount per token in the cache store and answer 429, withRetry-Afterand anIdempotency-Keyfor clients that retry safely.
Terms in this lesson
- bearer token
- A credential presented in the Authorization header as Bearer <token>; whoever holds the string can call the API as the identity the token represents, so its secrecy is the only protection.
- constant-time comparison
- A comparison that takes the same time whether strings match or differ, so an attacker cannot learn bytes of a secret by measuring how quickly the server says no.
- CORS
- Cross-Origin Resource Sharing: a protocol where the browser sends an Origin header and the server returns Access-Control-Allow-Origin to say which origins may read the response.
- Idempotency-Key
- A request header in which a client names one attempt of a request that must not run twice, such as a payment, so the server can run it once and answer retries with the stored response.
- JWT
- JSON Web Token: a signed, optionally encrypted, self-describing credential that carries claims (user id, expiry, scopes) in its payload, verified by checking the signature without a database lookup.
- preflight request
- An OPTIONS request a browser sends before a cross-origin request whose method or headers are not on the CORS safelist, such as one with an Authorization header; the answer says whether the real request may follow.
- Retry-After
- A response header, defined in RFC 9110, that tells a client how long to wait before its next request, in seconds or as a date; a 429 or a 503 answer may carry it.
- token digest
- A one-way hash of a token, usually SHA-256, stored in the database instead of the plain token so a leaked table does not hand the attacker every credential.
- token scope
- A list of permissions a token carries, usually stored as a space-separated string; the controller checks the scope before acting, so a read-only token cannot create resources.
References
- Cross-Origin Resource Sharing (CORS) (MDN Web Docs)
- Fetch Standard: CORS protocol (WHATWG)
- rack-cors (rack-cors)
- Using Rails for API-only Applications (Ruby on Rails)
- ActionController::HttpAuthentication::Token (Ruby on Rails)
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage (IETF)
- ActionController::RateLimiting::ClassMethods (Ruby on Rails)
- RFC 6585: Additional HTTP Status Codes (IETF)
- RFC 9110: HTTP Semantics (IETF)
- ActiveSupport::SecurityUtils (Ruby on Rails)
- ActiveSupport::Cache::MemoryStore (Ruby on Rails)
- The Idempotency-Key HTTP Header Field (Internet-Draft) (IETF)
- RFC 7519: JSON Web Token (JWT) (IETF)
- RFC 6749: The OAuth 2.0 Authorization Framework (IETF)
- Doorkeeper (Doorkeeper)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress