diff --git a/docs/how_tos/permissions.md b/docs/how_tos/permissions.md
new file mode 100644
index 00000000..1380d474
--- /dev/null
+++ b/docs/how_tos/permissions.md
@@ -0,0 +1,160 @@
+# How to: Query Permissions from openedx-authz
+
+## Overview
+
+`@openedx/frontend-base` provides hooks and utilities to validate user permissions against the
+`openedx-authz` service. Results are cached automatically via TanStack Query to minimize calls
+to the backend.
+
+## Prerequisites
+
+Ensure your app root is wrapped with a `QueryClientProvider` from `@tanstack/react-query`.
+
+---
+
+## Core Concepts
+
+### Permission query shape
+
+Permissions are expressed as a key/value map where:
+- **keys** are arbitrary semantic names you choose (e.g. `canEditGrading`)
+- **values** describe the `action` string and optional `scope` (resource identifier)
+
+```typescript
+import type { PermissionValidationQuery } from '@openedx/frontend-base';
+
+const query: PermissionValidationQuery = {
+ canViewGrading: {
+ action: 'courses.view_grading_settings',
+ scope: 'course-v1:org+course+run',
+ },
+ canEditGrading: {
+ action: 'courses.edit_grading_settings',
+ scope: 'course-v1:org+course+run',
+ },
+};
+```
+
+### Caching
+
+Results are cached using TanStack Query. The cache key includes the query object and the
+resolved `apiBaseUrl`, so different backends and different permission sets are cached
+independently. Results are reused across components that request the same permissions within
+one session.
+
+---
+
+## `usePermissions`
+
+The single hook for querying permissions. Requires a `featureEnabled` boolean — always
+pass the resolved waffle flag value so the caller explicitly opts in or out of authz.
+Permission keys are spread at the top level — no nested `.permissions` object.
+
+```typescript
+import { usePermissions } from '@openedx/frontend-base';
+
+// featureEnabled is required — always pass the resolved waffle flag boolean:
+const { enableAuthz } = useWaffleFlags(resourceId);
+const { isLoading, isError, isAuthzEnabled, canViewGrading, canEditGrading } = usePermissions(
+ {
+ canViewGrading: { action: 'courses.view_grading_settings', scope: resourceId },
+ canEditGrading: { action: 'courses.edit_grading_settings', scope: resourceId },
+ },
+ enableAuthz ?? false,
+);
+
+if (isLoading) { return ; }
+if (isError) { return ; }
+if (!canViewGrading) { return ; }
+```
+
+When `featureEnabled` is `false`: no API call is made and all keys return `true`,
+preserving the pre-authz behavior during rollout.
+
+To override the backend URL (e.g. MFEs using `@edx/frontend-platform`), pass `apiBaseUrl`
+in the options argument:
+
+```typescript
+import { usePermissions } from '@openedx/frontend-base';
+import { getConfig } from '@edx/frontend-platform';
+
+const { enableAuthz } = useWaffleFlags(courseId);
+const { isLoading, isError, canViewGrading } = usePermissions(
+ { canViewGrading: { action: 'courses.view_grading_settings', scope: courseId } },
+ enableAuthz ?? false,
+ { apiBaseUrl: getConfig().LMS_BASE_URL },
+);
+```
+
+> **Service unavailability:** if the authz API call fails, `isError` is `true` and all
+> permission keys resolve to `false`. Always check `isLoading` and `isError` before
+> rendering gated UI to avoid incorrectly denying access during transient failures.
+
+---
+
+## Recommended: create an MFE-specific wrapper
+
+Avoid calling `usePermissions` directly in every component. Create a single MFE-level
+wrapper that encapsulates the waffle flag check and base URL:
+
+```typescript
+import { usePermissions } from '@openedx/frontend-base';
+import { getConfig } from '@edx/frontend-platform';
+import { useWaffleFlags } from './waffleHooks'; // your MFE's waffle flag hook
+import type { PermissionValidationQuery } from '@openedx/frontend-base';
+
+export const useResourcePermissions = (
+ resourceId: string,
+ permissions: Query,
+) => {
+ const { enableAuthz } = useWaffleFlags(resourceId);
+ return usePermissions(
+ permissions,
+ enableAuthz ?? false,
+ { apiBaseUrl: getConfig().LMS_BASE_URL },
+ );
+};
+
+export const getResourcePermissions = (resourceId: string): PermissionValidationQuery => ({
+ canView: { action: 'resources.view', scope: resourceId },
+ canEdit: { action: 'resources.edit', scope: resourceId },
+});
+
+// Usage in any component:
+const { isLoading, canView, canEdit } =
+ useResourcePermissions(resourceId, getResourcePermissions(resourceId));
+```
+
+---
+
+## Best Practices
+
+- **Define permission constants** in your MFE (`COURSE_PERMISSIONS`, etc.) rather than
+ inline strings — prevents typos and makes global renames easy.
+- **Use query builder helpers** (`getGradingPermissions(courseId)`) to build the query
+ object — keeps permission definitions co-located with the feature they belong to.
+- **Do not duplicate `{ action, scope }` pairs** within a single query — only the first
+ matching key is mapped in the response.
+- **Keep `featureEnabled` close to the flag source** — the boolean should come directly
+ from your waffle flag check, not be stored in state or passed through many layers.
+
+---
+
+## Manual Cache Invalidation
+
+If user roles change mid-session and you need to force a refetch:
+
+```typescript
+import { permissionsQueryKeys } from '@openedx/frontend-base';
+import { getConfig } from '@edx/frontend-platform';
+
+// Default — URL comes from getSiteConfig().lmsBaseUrl (set via mergeSiteConfig):
+queryClient.invalidateQueries({
+ queryKey: permissionsQueryKeys.validate(myQuery),
+});
+
+// Explicit URL — use when you passed apiBaseUrl in UsePermissionsOptions:
+queryClient.invalidateQueries({
+ queryKey: permissionsQueryKeys.validate(myQuery, getConfig().LMS_BASE_URL),
+});
+```
diff --git a/runtime/authz/api.test.ts b/runtime/authz/api.test.ts
new file mode 100644
index 00000000..fe33cbc1
--- /dev/null
+++ b/runtime/authz/api.test.ts
@@ -0,0 +1,80 @@
+import { getAuthenticatedHttpClient } from '../auth';
+import { validatePermissions, PERMISSIONS_VALIDATE_PATH } from './api';
+
+jest.mock('../auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const BASE_URL = 'http://lms.example.com';
+const QUERY = {
+ canRead: { action: 'example.read', scope: 'lib:org:test' },
+ canWrite: { action: 'example.write', scope: 'lib:org:test' },
+};
+
+describe('validatePermissions', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('posts to the correct URL', async () => {
+ const postMock = jest.fn().mockResolvedValue({ data: [] });
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
+
+ await validatePermissions(BASE_URL, QUERY);
+
+ expect(postMock).toHaveBeenCalledWith(
+ `${BASE_URL}${PERMISSIONS_VALIDATE_PATH}`,
+ expect.any(Array),
+ );
+ });
+
+ it('sends all query items as an array in the request body', async () => {
+ const postMock = jest.fn().mockResolvedValue({ data: [] });
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
+
+ await validatePermissions(BASE_URL, QUERY);
+
+ const body = postMock.mock.calls[0][1];
+ expect(body).toHaveLength(2);
+ expect(body).toEqual(expect.arrayContaining([
+ { action: 'example.read', scope: 'lib:org:test' },
+ { action: 'example.write', scope: 'lib:org:test' },
+ ]));
+ });
+
+ it('maps response array back to caller keys', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [
+ { action: 'example.read', scope: 'lib:org:test', allowed: true },
+ { action: 'example.write', scope: 'lib:org:test', allowed: false },
+ ],
+ }),
+ });
+
+ const result = await validatePermissions(BASE_URL, QUERY);
+
+ expect(result).toEqual({ canRead: true, canWrite: false });
+ });
+
+ it('defaults missing keys to false', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({ data: [] }),
+ });
+
+ const result = await validatePermissions(BASE_URL, QUERY);
+
+ expect(result).toEqual({ canRead: false, canWrite: false });
+ });
+
+ it('defaults a partially missing key to false', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [{ action: 'example.read', scope: 'lib:org:test', allowed: true }],
+ }),
+ });
+
+ const result = await validatePermissions(BASE_URL, QUERY);
+
+ expect(result.canRead).toBe(true);
+ expect(result.canWrite).toBe(false);
+ });
+});
diff --git a/runtime/authz/api.ts b/runtime/authz/api.ts
new file mode 100644
index 00000000..0f843faf
--- /dev/null
+++ b/runtime/authz/api.ts
@@ -0,0 +1,41 @@
+import { getAuthenticatedHttpClient } from '../auth';
+import type {
+ PermissionValidationQuery,
+ PermissionValidationAnswer,
+ PermissionValidationRequestItem,
+ PermissionValidationResponseItem,
+} from './types';
+
+export const PERMISSIONS_VALIDATE_PATH = '/api/authz/v1/permissions/validate/me';
+
+/**
+ * Validates whether the currently authenticated user holds the requested permissions
+ * against the openedx-authz backend.
+ *
+ * @param apiBaseUrl - Base URL of the backend running openedx-authz (e.g. getConfig().LMS_BASE_URL).
+ * @param query - Key/value map of permission check descriptors.
+ * @returns Map of the same keys to boolean allowed values.
+ * Any key absent from the server response resolves to false.
+ */
+export const validatePermissions = async (
+ apiBaseUrl: string,
+ query: Query,
+): Promise> => {
+ const request: PermissionValidationRequestItem[] = Object.values(query);
+
+ const { data }: { data: PermissionValidationResponseItem[] }
+ = await getAuthenticatedHttpClient().post(
+ `${apiBaseUrl}${PERMISSIONS_VALIDATE_PATH}`,
+ request,
+ );
+
+ const result = {} as PermissionValidationAnswer;
+
+ for (const [key, reqItem] of Object.entries(query) as [keyof Query, PermissionValidationRequestItem][]) {
+ const match = data.find(
+ (item) => item.action === reqItem.action && item.scope === reqItem.scope,
+ );
+ result[key] = match ? match.allowed : false;
+ }
+ return result;
+};
diff --git a/runtime/authz/hooks.test.tsx b/runtime/authz/hooks.test.tsx
new file mode 100644
index 00000000..7a4f4b7d
--- /dev/null
+++ b/runtime/authz/hooks.test.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { getAuthenticatedHttpClient } from '../auth';
+import { usePermissions, permissionsQueryKeys } from './hooks';
+
+jest.mock('../auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const BASE_URL = 'http://lms.example.com';
+const QUERY = {
+ canView: { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run' },
+ canEdit: { action: 'courses.edit_grading_settings', scope: 'course-v1:org+course+run' },
+};
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('usePermissions', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('returns actual server values when featureEnabled is true', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [
+ { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run', allowed: true },
+ { action: 'courses.edit_grading_settings', scope: 'course-v1:org+course+run', allowed: false },
+ ],
+ }),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.canView).toBe(true);
+ expect(result.current.canEdit).toBe(false);
+ expect(result.current.isAuthzEnabled).toBe(true);
+ expect(result.current.isError).toBe(false);
+ });
+
+ it('returns all keys as true and makes no API call when featureEnabled is false', () => {
+ const postMock = jest.fn();
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ post: postMock });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, false, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+
+ expect(postMock).not.toHaveBeenCalled();
+ expect(result.current.canView).toBe(true);
+ expect(result.current.canEdit).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isError).toBe(false);
+ expect(result.current.isAuthzEnabled).toBe(false);
+ });
+
+ it('defaults absent server keys to false when featureEnabled is true', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({ data: [] }),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.canView).toBe(false);
+ expect(result.current.canEdit).toBe(false);
+ });
+
+ it('spreads permission keys at the top level — no nested .permissions object', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockResolvedValue({
+ data: [
+ { action: 'courses.view_grading_settings', scope: 'course-v1:org+course+run', allowed: true },
+ ],
+ }),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect('canView' in result.current).toBe(true);
+ expect('permissions' in result.current).toBe(false);
+ });
+
+ it('returns undefined permission keys and isLoading=true while the API call is in flight', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn(() => new Promise(() => {})), // never resolves
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.canView).toBeUndefined();
+ expect(result.current.canEdit).toBeUndefined();
+ });
+
+ it('sets isError=true and defaults all keys to false when the API call fails', async () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ post: jest.fn().mockRejectedValue(new Error('network error')),
+ });
+
+ const { result } = renderHook(
+ () => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.isError).toBe(true);
+ expect(result.current.canView).toBe(false);
+ expect(result.current.canEdit).toBe(false);
+ jest.restoreAllMocks();
+ });
+
+ it('scopes cache by apiBaseUrl — different base URLs produce distinct query keys', () => {
+ const keyA = permissionsQueryKeys.validate(QUERY, 'http://lms-a.example.com');
+ const keyB = permissionsQueryKeys.validate(QUERY, 'http://lms-b.example.com');
+ expect(keyA).not.toEqual(keyB);
+ });
+});
diff --git a/runtime/authz/hooks.ts b/runtime/authz/hooks.ts
new file mode 100644
index 00000000..d8bbbeb8
--- /dev/null
+++ b/runtime/authz/hooks.ts
@@ -0,0 +1,102 @@
+import { skipToken, useQuery } from '@tanstack/react-query';
+import { getSiteConfig } from '../config';
+import type { PermissionValidationQuery, PermissionValidationAnswer } from './types';
+import { validatePermissions } from './api';
+
+/**
+ * TanStack Query cache key factory for permission queries.
+ * Use `validate` to scope cache reads and invalidations to a specific
+ * query + backend combination.
+ *
+ * @example
+ * queryClient.invalidateQueries({ queryKey: permissionsQueryKeys.validate(myQuery) });
+ */
+export const permissionsQueryKeys = {
+ all: ['authz'] as const,
+ validate: (query: PermissionValidationQuery, apiBaseUrl: string = getSiteConfig().lmsBaseUrl) =>
+ [...permissionsQueryKeys.all, 'validatePermissions', apiBaseUrl, query] as const,
+};
+
+export interface UsePermissionsOptions {
+ /** Default false — authz returns definitive answers; retrying 403s wastes requests. */
+ retry?: boolean | number,
+ /**
+ * Base URL of the backend running openedx-authz.
+ * Defaults to getSiteConfig().lmsBaseUrl when omitted.
+ * Pass explicitly when the authz service requires a different backend (e.g. Studio).
+ */
+ apiBaseUrl?: string,
+}
+
+/**
+ * Intersection return type: metadata fields plus every permission key spread at the top level.
+ * Consumers destructure permission keys directly — no nested `.permissions` object.
+ *
+ * @example
+ * const { enableAuthz } = useWaffleFlags(courseId);
+ * const { isLoading, isError, canViewGrading, canEditGrading } = usePermissions(
+ * { canViewGrading: { action: 'courses.view_grading_settings', scope: courseId },
+ * canEditGrading: { action: 'courses.edit_grading_settings', scope: courseId } },
+ * enableAuthz ?? false,
+ * { apiBaseUrl: getConfig().LMS_BASE_URL },
+ * );
+ */
+export type UsePermissionsResult = {
+ isLoading: boolean,
+ isError: boolean,
+ isAuthzEnabled: boolean,
+} & { [K in keyof Query]: boolean | undefined };
+
+/**
+ * Queries the openedx-authz service for the given permissions.
+ *
+ * When featureEnabled is false: no API call is made; all permission keys return true,
+ * preserving the pre-authz behavior during gradual rollout.
+ * When featureEnabled is true: hits the authz API; returns actual server values.
+ *
+ * The caller is responsible for reading its own waffle flag and passing the result
+ * as featureEnabled — waffle flag names differ per MFE
+ *
+ * @param query - Key/value map of permission check descriptors.
+ * @param featureEnabled - Pass the result of your waffle flag check here.
+ * @param options - Optional retry and apiBaseUrl settings.
+ *
+ * @example
+ * const { enableAuthzCourseAuthoring } = useWaffleFlags(courseId);
+ * const { isLoading, canViewGrading, canEditGrading } = usePermissions(
+ * { canViewGrading: { action: 'courses.view_grading_settings', scope: courseId },
+ * canEditGrading: { action: 'courses.edit_grading_settings', scope: courseId } },
+ * enableAuthzCourseAuthoring ?? false,
+ * { apiBaseUrl: getConfig().LMS_BASE_URL },
+ * );
+ */
+export const usePermissions = (
+ query: Query,
+ featureEnabled: boolean,
+ options: UsePermissionsOptions = {},
+): UsePermissionsResult => {
+ const { retry = false, apiBaseUrl = getSiteConfig().lmsBaseUrl } = options;
+
+ const { isLoading, isError, data } = useQuery, Error>({
+ queryKey: permissionsQueryKeys.validate(query, apiBaseUrl),
+ queryFn: featureEnabled ? () => validatePermissions(apiBaseUrl, query) : skipToken,
+ retry,
+ });
+
+ const permissionResults = isLoading
+ ? ({} as PermissionValidationAnswer)
+ : (Object.keys(query) as (keyof Query)[]).reduce(
+ (acc, key) => {
+ acc[key] = featureEnabled ? (data?.[key] ?? false) : true;
+ return acc;
+ },
+ {} as PermissionValidationAnswer,
+ );
+
+ return {
+ isLoading: featureEnabled ? isLoading : false,
+ isError: featureEnabled ? isError : false,
+ isAuthzEnabled: featureEnabled,
+ ...permissionResults,
+ } as UsePermissionsResult;
+};
diff --git a/runtime/authz/index.ts b/runtime/authz/index.ts
new file mode 100644
index 00000000..f31dfd21
--- /dev/null
+++ b/runtime/authz/index.ts
@@ -0,0 +1,3 @@
+export { usePermissions, permissionsQueryKeys } from './hooks';
+export type { UsePermissionsOptions, UsePermissionsResult } from './hooks';
+export type { PermissionValidationQuery, PermissionValidationAnswer } from './types';
diff --git a/runtime/authz/types.ts b/runtime/authz/types.ts
new file mode 100644
index 00000000..cd9d6cfa
--- /dev/null
+++ b/runtime/authz/types.ts
@@ -0,0 +1,25 @@
+export interface PermissionValidationRequestItem {
+ action: string,
+ scope?: string,
+}
+
+export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
+ allowed: boolean,
+}
+
+export type PermissionValidationQuery = Record;
+
+/**
+ * Maps each key from the caller's query to a boolean allowed value.
+ * The generic form preserves exact key names for autocomplete and typo detection.
+ * Use the default (non-generic) form when the query shape is not statically known.
+ *
+ * @example
+ * const query = { canEdit: { action: 'courses.edit' } } satisfies PermissionValidationQuery;
+ * const answer: PermissionValidationAnswer = { canEdit: true };
+ */
+export type PermissionValidationAnswer<
+ Query extends PermissionValidationQuery = PermissionValidationQuery,
+> = {
+ [K in keyof Query]: boolean;
+};
diff --git a/runtime/index.ts b/runtime/index.ts
index 2cc94a7d..f54019a8 100644
--- a/runtime/index.ts
+++ b/runtime/index.ts
@@ -144,3 +144,5 @@ export {
} from './utils';
export * from './slots';
+
+export * from './authz';