Rails API-authenticatie: JWT, Sessiecookies en API-sleutels — Wanneer Gebruik Je Wat
Rails API-authenticatie in 2026: JWT, sessiecookies en API-sleutels vergeleken — beveiligingsafwegingen, Rails 8-code en hoe je de juiste aanpak kiest.
Een founder belde me vorig jaar op nadat hun startup een beveiligingsaudit had doorstaan — op het nippertje. De bevinding van de auditor was als volgt: de Rails API had gemiddeld 47 geldige JWT-tokens per gebruiker. Tokens werden bij elke login aangemaakt. Ze verliepen nooit. Er was geen refresh-flow. Toen het team doorkreeg dat tokens niet konden worden ingetrokken zonder de signing-sleutel te roteren — wat elke gebruiker in productie zou uitloggen — hebben ze snel een Redis-backed token-blacklist naast de JWT-implementatie gezet. Ze hadden een stateless authenticatiesysteem gebouwd en er vervolgens stateful state aan toegevoegd, alleen slechter. Het woord “stateless” was een cargo cult geworden.
Rails API-authenticatie is een van die gebieden waar de verkeerde keuze jarenlang doorwerkt. Na negentien jaar Rails heb ik elke combinatie gezien — sessies voor alles, JWT voor alles, zelfgemaakte token-schema’s die aan een draadje hingen — en het patroon dat ik altijd zie is dat mensen voor JWT kiezen omdat het modern klinkt, om vervolgens achttien maanden lang de beperkingen ervan te omzeilen. Dit artikel is het beslissingskader dat ik aan klanten geef voordat er ook maar één regel auth-code wordt geschreven.
De Drie Patronen en Wat Ze Daadwerkelijk Oplossen
Er zijn precies drie authenticatiemechanismen die het overwegen waard zijn voor een Rails API in 2026:
Sessiecookies. De browser slaat een versleuteld, getekend sessie-ID op. Elk verzoek stuurt de cookie mee. Je Rails-applicatie zoekt de sessie op aan de serverzijde — in de cookie store (signed cookie), de database, Redis of Solid Cache. De server weet of de sessie op dit moment geldig is, omdat hij de bron van waarheid is. Intrekken is het verwijderen van één databaserij.
JWT (JSON Web Tokens). De client slaat een cryptografisch getekende blob op die claims bevat. Je Rails-applicatie verifieert de handtekening zonder database-opzoeking. De token is geldig totdat hij verloopt. Je kunt een geldige token niet intrekken vóór de verloopdatum zonder een intrekkingslijst bij te houden — wat je terugbrengt naar de behoefte aan server-side state.
API-sleutels. Een lange willekeurige string, doorgaans een Bearer-token in de Authorization-header. De server slaat een hash van de sleutel op en zoekt die bij elk verzoek op. Ontworpen om langdurig te bestaan. Beperkt tot specifieke rechten. Direct te roteren of in te trekken. Bedoeld voor machine-to-machine gebruik.
Elk van deze patronen lost een echt probleem op. Geen enkel is een universele vervanging voor de andere.
Sessiecookies: De Juiste Standaard voor Browsertoepassingen
Als je een Rails-applicatie bouwt met een browser-frontend — zelfs een React of Vue SPA — zijn sessiecookies vrijwel altijd het juiste antwoord. De Rails 8-authenticatiegenerator levert standaard een op sessies gebaseerd model, en niet zonder reden: HTTP-only cookies kunnen niet worden gestolen via XSS, SameSite=Lax blokkeert CSRF bij cross-origin verzoeken, en intrekking is onmiddellijk.
Het gegenereerde Session-model ziet er zo uit:
class Session < ApplicationRecord
belongs_to :user
before_create { self.token = SecureRandom.urlsafe_base64(32) }
end
En de authenticatie-concern:
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
private
def authenticated?
@current_session.present?
end
def require_authentication
resume_session || request_authentication
end
def resume_session
@current_session = Session.find_by(token: cookies.signed[:session_token])
@current_user = @current_session&.user
end
def start_new_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = {
value: session.token,
httponly: true,
samesite: :lax,
secure: Rails.env.production?
}
@current_session = session
@current_user = user
end
end
De httponly: true-vlag is de regel die er het meest toe doet. JavaScript kan een HTTP-only cookie niet lezen. Dat betekent dat een XSS-kwetsbaarheid in je React-frontend het sessietoken niet kan exfiltreren. Vergelijk dit met een JWT opgeslagen in localStorage — localStorage is volledig leesbaar voor elk script op de pagina, wat de standaard aanbeveling is “sla JWT’s op in geheugen, niet in localStorage”, wat leidt tot “sla op in een HTTP-only cookie”, wat gewoon een sessie is.
De instelling SameSite: :lax blokkeert de cookie bij cross-site POST-verzoeken, waarmee het CSRF-aanvalsoppervlak voor state-wijzigende operaties wordt geëlimineerd. Rails levert nog steeds een CSRF-token in de response-headers voor browsers die dat nodig hebben, maar SameSite=Lax is nu de primaire verdediging op moderne browsers.
De cross-origin uitzondering. Als je frontend op app.example.com staat en je API op api.example.com, werken cookies prima — zelfde eTLD+1. Als je frontend op app.vercel.app staat en je API op api.example.com, heb je een echt cross-origin probleem. Dan moet je ofwel domeinen samenvoegen, ofwel voor JWT kiezen voor het browser-naar-API verkeer. Dit is het ene legitieme geval waarin JWT sessies vervangt voor browserverkeer.
JWT: Wanneer Het Echt Helpt
JWT is geen sessievervanging. Het is een tokenformaat voor scenario’s waar je zelfvoorzienende, verifieerbare claims nodig hebt die zonder database-opzoeking kunnen worden gecontroleerd. De legitieme gebruiksgevallen zijn smaller dan de meeste teams aannemen.
Service-to-service calls met ingebedde claims. Service A moet Service B aanroepen en bewijzen dat het verzoek voor gebruiker-ID 7412 met rol admin is. In plaats van dat Service B een terugbelverzoek doet aan Service A om te verifiëren, ondertekent Service A een JWT met die claims. Service B verifieert de handtekening met de publieke sleutel van Service A. Geen netwerkoproep aan de verificatiezijde.
Kortlevende operatietokens. Een gebruiker vraagt om een wachtwoordreset. Je geeft een JWT uit met een verloopdatum van 15 minuten die het gebruikers-ID en een claim purpose: reset_password bevat. Geen database-state vereist. Hetzelfde patroon werkt voor e-mailverificatielinks en magic login-links.
Voorondertekende download-URL’s voor privébestanden. Je wilt een gebruiker een tijdgebonden URL geven om een privé S3-bestand te downloaden zonder dat je server als proxy fungeert. Geef een kortlevende JWT uit, versleutel deze in de URL en verifieer deze aan de rand.
Voeg voor deze gevallen de jwt-gem toe en wikkel hem in:
# Gemfile
gem "jwt", "~> 2.9"
# app/models/concerns/jwt_issuable.rb
module JwtIssuable
extend ActiveSupport::Concern
ALGORITHM = "RS256".freeze
class_methods do
def issue_jwt(payload, expires_in: 15.minutes)
claims = payload.merge(
iat: Time.current.to_i,
exp: expires_in.from_now.to_i,
iss: "api.example.com"
)
JWT.encode(claims, private_key, ALGORITHM)
end
def verify_jwt(token, purpose: nil)
payload, _header = JWT.decode(
token,
public_key,
true,
algorithms: [ALGORITHM],
iss: "api.example.com",
verify_iss: true
)
if purpose && payload["purpose"] != purpose.to_s
raise JWT::DecodeError, "Token purpose mismatch"
end
payload
rescue JWT::ExpiredSignature
raise
rescue JWT::DecodeError => e
Rails.logger.warn("JWT decode failed: #{e.message}")
nil
end
private
def private_key
OpenSSL::PKey::RSA.new(ENV.fetch("JWT_PRIVATE_KEY").gsub("\\n", "\n"))
end
def public_key
OpenSSL::PKey::RSA.new(ENV.fetch("JWT_PUBLIC_KEY").gsub("\\n", "\n"))
end
end
end
Twee dingen in die code zijn ononderhandelbaar:
- Geef altijd
algorithms:mee aanJWT.decode. Als je de algoritmenlijst weglaat, accepteert de bibliotheek elk algoritme dat de tokenheader aangeeft — inclusief"none", wat betekent: geen handtekening. Dit is CVE-2015-9235, en JWT-bibliotheken zijn hier herhaaldelijk door verbrand. - Gebruik RS256, niet HS256, voor service-to-service tokens. HS256 vereist het delen van de geheime sleutel. RS256 gebruikt een sleutelpaar — je distribueert de publieke sleutel naar verifiërende services zonder iets onderteekenbaars bloot te stellen.
Genereer het sleutelpaar eenmalig:
openssl genrsa -out jwt_private.pem 2048
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
Sla beide op in Rails credentials of omgevingsvariabelen. De privésleutel ondertekent; de publieke sleutel verifieert.
Gebruik op een call site:
class PasswordResetsController < ApplicationController
skip_before_action :require_authentication, only: %i[create update]
def create
user = User.find_by(email: params[:email])
if user
token = JwtIssuable.issue_jwt(
{ user_id: user.id, purpose: "password_reset" },
expires_in: 15.minutes
)
PasswordResetMailer.with(user: user, token: token).reset_email.deliver_later
end
render json: { message: "Als dat e-mailadres bestaat, is er een resetlink onderweg." }
end
def update
payload = JwtIssuable.verify_jwt(params[:token], purpose: :password_reset)
return render json: { error: "Ongeldige of verlopen link" }, status: :unprocessable_entity if payload.nil?
user = User.find(payload["user_id"])
user.update!(password: params[:password], password_confirmation: params[:password_confirmation])
render json: { message: "Wachtwoord bijgewerkt." }
end
end
Wat de bovenstaande code niet doet, is JWT als sessievervanging gebruiken. Geen refresh tokens. Geen Authorization: Bearer bij elk API-verzoek. De token is eenmalig, beperkt tot één doel en verloopt in 15 minuten. Dit is JWT dat doet waarvoor het goed is.
API-sleutels: Machine-to-Machine Authenticatie
Als een B2B-klant je API aanroept vanuit hun backend-service — nachtelijkse data-exports, webhook-integraties, tools van derden — hebben ze API-sleutels nodig, geen sessies en geen JWT. Sessies zijn ontworpen voor gebruikers; JWT heeft een tijdstempelprobleem (je moet een verloopdatum kiezen, en als je iets langer dan een uur kiest, stapel je intrekkingsschuld op). API-sleutels zijn ontworpen voor servicepricipals: langdurig, beperkt tot specifieke rechten, roteerbaar zonder andere sleutels te breken, en onmiddellijk in te trekken.
De implementatie is eenvoudig. Sla de sleutelhash op, niet de sleutel zelf — hetzelfde principe als bij wachtwoorden:
class CreateApiKeys < ActiveRecord::Migration[8.0]
def change
create_table :api_keys do |t|
t.references :user, null: false, foreign_key: true
t.string :name, null: false
t.string :token_digest, null: false
t.string :prefix, null: false
t.jsonb :scopes, default: []
t.datetime :last_used_at
t.datetime :expires_at
t.timestamps
end
add_index :api_keys, :token_digest, unique: true
add_index :api_keys, :prefix
end
end
De kolom prefix is een leesbare aanwijzing — zoiets als ttb_live_ dat in de volledige sleutel verschijnt. Hiermee kunnen gebruikers identificeren welke sleutel ze bekijken zonder dat je database iets onthullends opslaat. De token_digest is Digest::SHA256.hexdigest(token):
class ApiKey < ApplicationRecord
belongs_to :user
TOKEN_PREFIX = "ttb_live_".freeze
def self.generate_for(user, name:, scopes: [])
raw_token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(32)}"
key = create!(
user: user,
name: name,
prefix: raw_token.first(12),
token_digest: Digest::SHA256.hexdigest(raw_token),
scopes: scopes
)
[key, raw_token]
end
def self.authenticate(raw_token)
digest = Digest::SHA256.hexdigest(raw_token)
key = find_by(token_digest: digest)
return nil unless key
return nil if key.expires_at&.past?
key.touch(:last_used_at)
key
end
def allows?(scope)
scopes.empty? || scopes.include?(scope.to_s)
end
end
De authenticatie-concern voor API-eindpunten:
module ApiKeyAuthentication
extend ActiveSupport::Concern
included do
before_action :require_api_key
end
private
def require_api_key
raw_token = request.headers["Authorization"]&.delete_prefix("Bearer ")
return render_unauthorized unless raw_token.present?
@current_api_key = ApiKey.authenticate(raw_token)
return render_unauthorized unless @current_api_key
@current_user = @current_api_key.user
end
def require_scope(scope)
render_unauthorized unless @current_api_key.allows?(scope)
end
def render_unauthorized
render json: { error: "Unauthorized" }, status: :unauthorized
end
end
Eén detail dat in de meeste tutorials wordt gemist: de Digest::SHA256.hexdigest-vergelijking is veilig tegen timing-aanvallen omdat SHA256 een bewerking met vaste lengte is — de vergelijkingstijd lekt geen informatie over gedeeltelijke overeenkomsten. Als je de ruwe token rechtstreeks vergeleek (in plaats van eerst te hashen), zou je ActiveSupport::SecurityUtils.secure_compare nodig hebben. Hash het en vergelijk hashes; == is prima.
De last_used_at-touch is de moeite waard. Hiermee kun je tooling bouwen die klanten laat zien welke sleutels actief zijn en sleutels markeert die 90 dagen niet zijn gebruikt voor rotatieherinneringen.
De Drie Benaderingen Combineren
Echte productie-Rails-applicaties gebruiken alle drie patronen tegelijkertijd, gelaagd per clienttype. Dit is de routingstructuur die ik gebruik bij elke Rails API-authenticatie-inrichting:
# config/routes.rb
Rails.application.routes.draw do
# Browsergericht: sessiecookie-auth
scope module: :web do
get "/login", to: "sessions#new"
post "/login", to: "sessions#create"
delete "/logout", to: "sessions#destroy"
resource :dashboard, only: :show
end
# API v1: API-sleutel-auth
namespace :api do
namespace :v1 do
resources :exports, only: %i[index create show]
resources :webhooks, only: %i[index create destroy]
end
end
# Interne serviceaanroepen: JWT-auth (uitgegeven door je eigen services)
namespace :internal do
resources :users, only: :show
end
end
# app/controllers/web/base_controller.rb
class Web::BaseController < ApplicationController
include Authentication # sessiecookie-auth
end
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
include ApiKeyAuthentication
skip_before_action :verify_authenticity_token
end
# app/controllers/internal/base_controller.rb
class Internal::BaseController < ApplicationController
before_action :verify_internal_token
skip_before_action :verify_authenticity_token
private
def verify_internal_token
payload = JwtIssuable.verify_jwt(request.headers["X-Internal-Token"])
return render json: { error: "Unauthorized" }, status: :unauthorized unless payload
@claims = payload
end
end
De skip_before_action :verify_authenticity_token op API-controllers is de juiste keuze — CSRF-bescherming is irrelevant voor eindpunten die Authorization-headers ontvangen in plaats van cookies. Laat CSRF-bescherming aan bij de op cookies gebaseerde browsercontrollers.
Mobiele clients (iOS/Android) zijn de ene nuance. Als je mobiele app dezelfde origin heeft als de API, geef dan kortlevende JWT’s uit via je auth-eindpunt en laat de mobiele client ze vernieuwen. Als je bouwt voor een integratie door derden op mobiel, behandel het dan als een API-sleutelclient. Het slechtste patroon dat ik zie is mobiele apps die langlevende JWT’s opslaan in de apparaat-keychain die 30 dagen geldig zijn — je hebt effectief een API-sleutel gebouwd met extra stappen en zonder intrekkingspad.
Beveiligingspatronen Die Er Echt Toe Doen
Na de drie benaderingen zijn dit de beveiligingspraktijken die productieklare Rails API-authenticatie onderscheiden van tutorialcode:
Beperk auth-eindpunten qua aanvragen. Elk loginformulier en token-uitgifte-eindpunt heeft rate limiting nodig. De Rack::Attack rate limiting post behandelt de inrichting. Zonder dat is je login-eindpunt een open uitnodiging voor credential stuffing.
Log auth-events, nooit auth-credentials. Log succesvolle en mislukte authenticatiepogingen met IP, user agent en gebruikers-ID (of e-mail, vóór opzoeking). Log nooit de ruwe token, het wachtwoord of de API-sleutel — zelfs niet de eerste paar tekens.
# app/models/concerns/authentication.rb
def resume_session
@current_session = Session.find_by(token: cookies.signed[:session_token])
if @current_session
Rails.logger.info(
"auth.session_resumed user_id=#{@current_session.user_id} ip=#{request.remote_ip}"
)
end
@current_user = @current_session&.user
end
Verval sessies bij wachtwoordwijziging. Wanneer een gebruiker zijn wachtwoord wijzigt, verwijder dan alle andere actieve sessies. Het sessiemodel van Rails 8 maakt dit één regel:
def update
if @user.update(user_params)
@user.sessions.where.not(id: current_session).destroy_all
redirect_to dashboard_path, notice: "Wachtwoord bijgewerkt."
end
end
Roteer API-sleutels, niet signing-secrets. Wanneer een JWT signing-secret is gecompromitteerd, moet je hem roteren, waardoor elke token die in omloop is ongeldig wordt. Wanneer een API-sleutel is gecompromitteerd, roteer je die ene sleutel. Dit is het praktische argument voor API-sleutels boven JWT voor langdurige machinetoegang: de impactradius van een compromittering is beperkt.
Het Beslissingskader in Één Tabel
Wanneer ik een klant adviseer over Rails API-authenticatie, doorloop ik dit:
| Clienttype | Auth-mechanisme | Reden |
|---|---|---|
| Browser (same-origin SPA of SSR) | Sessiecookies | HTTP-only, SameSite, onmiddellijke intrekking |
| Browser (cross-origin) | Kortlevende JWT | Cookies geblokkeerd cross-origin; JWT in geheugen of HTTP-only |
| Mobiele app (eigen) | Kortlevende JWT + refresh | Kan geen cookies gebruiken; houd verloopdatum ≤60 min |
| B2B API-integratie | API-sleutels | Langdurig, beperkt, roteerbaar |
| Service-to-service aanroep | JWT (RS256) | Claims zonder DB-opzoeking, kortlevend |
| Wachtwoordreset / magic link | JWT (eenmalig, 15 min) | Stateless, zelfverlopen |
De founder wiens JWT-blacklist ik vorig jaar auditeerde, draait nu sessiecookies voor hun browserapp, kortlevende JWT’s voor hun mobiele clients en API-sleutels voor hun enterprise-integraties. De Redis-blacklist is weg. Het intrekkingsprobleem is opgelost. De auditbevinding is gesloten.
Veelgestelde Vragen
Moet ik JWT gebruiken voor mijn Rails API?
Alleen als je een specifieke reden hebt. Het voordeel van JWT — geen database-opzoeking bij verificatie — is reëel maar zelden de bottleneck. Als je een browserapp bouwt, zijn sessiecookies veiliger en eenvoudiger correct te implementeren. Als je service-to-service calls bouwt, zijn kortlevende JWT’s met RS256-handtekeningen het juiste gereedschap. Als je langdurige machine-credentials nodig hebt, zijn API-sleutels beter dan JWT omdat ze intrekbaar zijn zonder verloopdatumgymnastiek.
Hoe verwerk ik JWT refresh tokens in Rails?
Refresh tokens zijn zelf een vorm van server-side state. Geef de refresh token uit als een willekeurige ondoorzichtige token die in je database is opgeslagen (zoals een API-sleutel), houd hem buiten de JWT-payload en wissel hem in voor een nieuwe kortlevende access-JWT via een speciaal /auth/refresh-eindpunt. Embed de refresh token nooit in de access token. Sla geen van beide tokens op in localStorage. Als je zoveel infrastructuur schrijft om JWT in stand te houden, vraag je dan af of sessiecookies een eenvoudiger antwoord zijn voor jouw gebruik.
Kan ik sessiecookies gebruiken met een React of Next.js frontend?
Ja, als ze op hetzelfde domein of dezelfde eTLD+1 staan. Een Next.js-app op app.example.com die praat met een Rails API op api.example.com kan cookies delen met domain: ".example.com". Stel SameSite: :lax en Secure: true in productie in. Je hebt config.action_dispatch.cookies_same_site_protection = :lax in je Rails-initializer nodig en CORS geconfigureerd om credentials op te nemen. Het cross-origin geval — app.vercel.app → api.example.com — vereist echt JWT of een ander niet-cookie-mechanisme omdat SameSite-restricties voorkomen dat de cookie cross-site wordt verstuurd.
Hoe trek ik JWT-tokens in Rails in vóór ze verlopen?
Het eerlijke antwoord is: dat kun je niet, zonder server-side state. De gangbare aanpakken zijn een Redis-backed token-blocklist (een set ingetrokken JID’s — JWT-ID’s — die je bij elke verificatie controleert), de verloopdatum heel kort houden (15 minuten) en dat venster accepteren als intrekkingslatentie, of een ondoorzichtige refresh token gebruiken die je onmiddellijk kunt intrekken en die de kortlevende access token bij zijn natuurlijke verloopdatum ongeldig maakt. Het diepere antwoord is: als je onmiddellijke intrekking nodig hebt, is JWT het verkeerde gereedschap. Gebruik sessiecookies (browser) of API-sleutels (machine), die beide onmiddellijke intrekking ondersteunen zonder extra infrastructuur.
De verkeerde authenticatieprimitive kiezen voor een Rails API creëert technische schuld die jarenlang doorwerkt. Als je een nieuwe auth-laag ontwerpt of een bestaande auditeert, helpt TTB Software Rails-teams bij het bouwen van authenticatie die veilig, onderhoudbaar en daadwerkelijk passend is voor elk clienttype. Negentien jaar productie-Rails — we weten wat de verkeerde keuze kost.
Related Articles
Rails pgvector: Semantische Zoek en RAG met PostgreSQL voor LLM-Applicaties
Rails pgvector gids: bouw semantische zoek en RAG op PostgreSQL met embeddings, HNSW-indexen en hybride retrieval. Pr...
Rails Passkeys: WebAuthn Passwordless Authenticatie met webauthn-ruby in Rails 8
Rails passkeys en WebAuthn in Rails 8: bouw productie-passwordless authenticatie met webauthn-ruby, van registratiefl...
Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolatie met RLS Policies
Rails PostgreSQL Row-Level Security voor multi-tenant SaaS: hoe je RLS-policies opzet, sessievariabelen inzet en veil...