RUBY ON RAILS · 21 MIN READ ·

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.

Rails API-authenticatie: JWT, Sessiecookies en API-sleutels — Wanneer Gebruik Je Wat

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 aan JWT.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.appapi.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.

#rails-api-authentication #rails-jwt-authentication #rails-api-keys #jwt-vs-sessions-rails #rails-bearer-token-authentication #rails-api-security

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software