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.