Stateless JWT auth via Devise: a short-lived signed cookie issued on login, refreshed transparently by the frontend when it expires.

config/initializers/devise.rb
Ruby
lines 46–95
  # ─────────────────────────────────────────────
  # JWT configuration (devise-jwt)
  # ─────────────────────────────────────────────
  jwt_secret =
    Rails.application.credentials.dig(:devise, :jwt_secret_key) ||
    ENV["DEVISE_JWT_SECRET_KEY"]

  if jwt_secret.blank?
    message = "[Devise/JWT] Missing JWT secret key. " \
              "Set devise.jwt_secret_key in credentials " \
              "or DEVISE_JWT_SECRET_KEY in the environment."

    if Rails.env.test?
      # In test/CI we allow a fallback so the suite can still run.
      Rails.logger.warn(message)
      jwt_secret = "test-fallback-jwt-secret-change-me"
    else
      # In non-test environments we fail fast.
      Rails.logger.error(message)
      raise "Devise JWT secret key is not configured"
    end
  end

  config.jwt do |jwt|
    jwt.secret = jwt_secret

    # Issue token on login
    jwt.dispatch_requests = [
      ["POST", %r{^/api/v1/login$}]
    ]

    # Revoke/blacklist token on logout
    jwt.revocation_requests = [
      ["DELETE", %r{^/api/v1/logout$}]
    ]

    jwt.expiration_time = 2.hours.to_i
    jwt.request_formats  = { amigo: [:json] }
  end

  # Pepper – used as an additional secret in password hashing
  config.pepper =
    Rails.application.credentials.dig(:devise, :pepper) ||
    ENV["DEVISE_PEPPER"]

  # Custom failure app for JSON errors instead of redirects
  config.warden do |manager|
    manager.failure_app = CustomFailureApp
  end
end

Fails fast in production if no JWT secret is configured, but falls back to a clearly-labeled test value in CI so the suite still runs.

app/controllers/api/v1/auth/sessions_controller.rb
Ruby
lines 39–76 (create)
        # POST /api/v1/login
        def create
          amigo = authenticate_amigo(params[:amigo])
          return unless amigo

          # Sign a new JWT and set cookie
          expires_at = ACCESS_TOKEN_TTL.from_now
          token      = JsonWebToken.encode({ sub: amigo.id }, expires_at)

          log_token_presence('create-before')
          set_jwt_cookie(token, expires_at)
          log_token_presence('create-after')

          # Also (re)issue CSRF token cookie
          cookies['CSRF-TOKEN'] = {
            value:     form_authenticity_token,
            same_site: Rails.env.development? ? :none : :strict,
            secure:    true,
            http_only: false,
            path:      '/'
          }

          amigo_attrs = {
            id:         amigo.id,
            user_name:  amigo.user_name,
            email:      amigo.email,
            first_name: amigo.first_name,
            last_name:  amigo.last_name
          }

          render json: {
            status: { code: 200, message: 'Logged in successfully.' },
            data:   {
              amigo:          amigo_attrs,
              jwt_expires_at: expires_at.utc.iso8601
            }
          }, status: :ok
        end

Login issues a signed, HTTP-only JWT cookie and a separate readable CSRF cookie in the same response.

src/services/api/privateApi.ts
TypeScript
lines 175–281 (response interceptor)
privateApi.interceptors.response.use(
  (res: AxiosResponse) => res,
  async (error: AxiosError) => {
    const original = error.config as
      | (InternalAxiosRequestConfig & { _retry?: boolean })
      | undefined;

    const status = error.response?.status;
    const url = original?.url;

    if (!original || status !== 401 || original._retry || isAuthEndpoint(url)) {
      return Promise.reject(error);
    }

    original._retry = true;

    try {
      if (!isRefreshing) {
        isRefreshing = true;

        const csrf = await ensureCsrfToken().catch(() => undefined);
        const headers = new AxiosHeaders();
        if (csrf) headers.set("X-CSRF-Token", csrf);

        const defaultsHeaders = getMutableDefaultHeaders();
        const currentAuth = defaultsHeaders.common?.Authorization;

        if (currentAuth) {
          headers.set("Authorization", currentAuth);
        }

        const res = await publicApi.post<RefreshResponseBody>(
          "/api/v1/refresh_token",
          undefined,
          {
            headers,
            withCredentials: true,
            validateStatus: (s) => s === 200 || s === 401,
          }
        );

        isRefreshing = false;

        if (res.status === 200) {
          const rawToken = extractAuthToken(res);

          if (rawToken) {
            const value = rawToken.startsWith("Bearer")
              ? rawToken
              : `Bearer ${rawToken}`;

            const mutable = getMutableDefaultHeaders();
            mutable.common = {
              ...(mutable.common ?? {}),
              Authorization: value,
            };

            try {
              if (typeof window !== "undefined") {
                localStorage.setItem("authToken", value);
              }
            } catch {
              void 0;
            }
          }

          return privateApi.request(original);
        }
      } else {
        await new Promise((resolve) => setTimeout(resolve, 200));
        return privateApi.request(original);
      }
    } catch {
      isRefreshing = false;
    }

    // ...clear stored auth, reset CSRF, surface an auth-required error
    return Promise.reject(error);
  }
);

One central 401 handler: a single in-flight refresh (concurrent requests wait on it rather than each firing their own), then the original request is retried exactly once.