Add read-only permissions to your Rails API tokens
Guillaume Briday
4 minutes
Slog-app, my time tracking SaaS, exposes a public REST API following the JSONAPI specification.
Authentication is straightforward: the user generates a personal API token from their profile, and sends it as a Bearer token. Simple, but until recently that token was all or nothing: whoever held it could list your time entries and delete every project you own.
That was already not great for a script or a third-party integration. It became genuinely uncomfortable the day an LLM started calling those endpoints on the user’s behalf. Plugging an agent into your account to answer “how many hours did I bill last week?” shouldn’t require handing it the ability to wipe your data.
So I added a permission to each token: read or read+write. Nothing exotic, it’s what most providers already do: OpenAI, for instance, lets you create API keys that are read-only, restricted to a few endpoints, or fully permissive.
Here’s how it’s implemented.
An enum on the model
There’s no need for a full-blown scope system with a scopes table, a bitmask and a permission DSL. Two levels are enough, and an integer column with an enum covers it:
# app/models/api_token.rb
class ApiToken < ApplicationRecord
has_secure_token :token, length: 36
encrypts :token, deterministic: true
enum :permission, {
read: 0,
write: 1
}
belongs_to :user
# ...
# @param [String] method
def allows?(method)
method.in?(%w[GET HEAD]) || write?
end
end
The allows? method is the entire authorization rule: a safe HTTP verb is always fine, anything else requires a write token.
The migration is where the only real subtlety lives. The new column defaults to read, which is what I want for new tokens, but existing tokens were created back when every token could write. Silently downgrading them would break every integration my users already have in production, so they’re backfilled with write:
# db/migrate/20251211190249_add_permission_to_api_tokens.rb
class AddPermissionToApiTokens < ActiveRecord::Migration[8.0]
def change
add_column :api_tokens, :permission, :integer, default: 0, null: false
up_only do
# This was the default
ApiToken.update_all(permission: :write) # rubocop:disable Rails/SkipsModelValidations
end
end
end
up_only is the right tool here. The backfill only makes sense going forward, and running it on a rollback would be meaningless since the column is dropped anyway. It keeps the migration reversible without splitting it into up and down methods.
update_all skips validations and callbacks on purpose, we’re updating a single integer column on every row in one query.
Guarding the API
The API authenticates in a before_action in the base controller. Adding the permission check there means every endpoint is covered at once, without touching a single controller:
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_user_from_token!, :authenticate_user!
private
def authenticate_user_from_token!
authenticate_with_http_token do |token, _options|
token = ApiToken.find_by(token: token)
render json: { error: I18n.t('devise.failure.invalid_token') }, status: :unauthorized and return if token.blank? || !token.allows?(request.method)
token.update(last_used_at: Time.current)
sign_in(token.user, store: false)
end
end
end
A read-only token sending a POST gets the exact same 401 as an invalid token. That’s deliberate, there’s no reason to tell a caller which part of its credentials is insufficient.
This works because the API is properly RESTful: GET reads, POST/PATCH/DELETE write. If you have endpoints that mutate state behind a GET, this rule will happily let them through, and you have a bigger problem to fix first.
Note that this happens before Pundit. The token permission answers “is this credential allowed to write at all?”, the policies still answer “is this user allowed to touch this record?”. Both checks run, and the token one can only ever be more restrictive.
Exposing it to users
An enum gives you the select options almost for free, the only thing worth writing is the helper that translates them:
# app/helpers/user_helper.rb
def api_token_permission_options
ApiToken.permissions.map do |key, _|
[t("activerecord.attributes.api_token.permissions.#{key}"), key]
end
end
<%# app/views/users/api_tokens/_form.html.erb %>
<%= f.select :permission,
options_for_select(api_token_permission_options, api_token.permission),
{},
class: 'form-control' %>
# config/locales/models/api_tokens.en.yml
en:
activerecord:
attributes:
api_token:
permission: Permission
permissions:
read: Read
write: Read+Write
Which gives this form:

The permission is also shown in the token list, so a user can audit at a glance what each of their tokens is able to do, and I added it to the Administrate dashboard for support purposes.
One deliberate omission: the permission stays editable. You might prefer to make it immutable after creation, because forcing users to generate a new token to widen its scope is more explicit, and it means a leaked read-only token can’t be silently upgraded by someone who also has session access.
Testing it
The model spec is trivial, and honestly the most valuable one, because allows? is the single rule the whole API depends on:
# spec/models/api_token_spec.rb
describe '#allows?' do
context 'when permission is read-only' do
it 'is true for read requests only' do
expect(api_token.allows?('GET')).to be(true)
expect(api_token.allows?('HEAD')).to be(true)
expect(api_token.allows?('POST')).to be(false)
expect(api_token.allows?('DELETE')).to be(false)
end
end
context 'when permission is read and write' do
before { api_token.permission = :write }
it 'is true for all requests' do
expect(api_token.allows?('GET')).to be(true)
expect(api_token.allows?('POST')).to be(true)
expect(api_token.allows?('DELETE')).to be(true)
end
end
end
Note that the factory has no permission, so a token created without one gets the column default, read. If someone ever flips that default to write, the request specs fail immediately.
Which is exactly what happened to the existing API suite, where the token now has to be created explicitly with permission: :write to keep hitting the mutating endpoints:
let(:api_token) { create(:api_token, user: user, permission: :write) }
That one-line change across the request specs was the whole migration cost on the test side.
Conclusion
The whole feature is one column, one enum and a five-line method called in a before_action. Two levels instead of granular per-resource scopes, because that’s what my users actually need today, and I can always split write further later without another data migration.
If your API is RESTful, the HTTP verb already tells you everything you need to know, and a read-only token is a couple of hours of work. Well worth it the day someone wants to hand your API to an agent.
That’s it for this blog post, hope you found it helpful! Happy coding! 🚀
