Build CoreOrdered learning track

Access Request Workflows

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 060

Deep implementation guide for access request workflows in React: request access, approval chains, temporary grants, expiry, delegation, auditability, and production-grade permission recovery.

8 min read1519 words
PrevNext
Lesson 60130 lesson track25–71 Build Core
#react#authorization#access-request#approval-workflow+6 more

Part 060 — Access Request Workflows

A mature authorization system does not stop at denial.

It gives the user a safe path to ask for access.

But access request workflows are dangerous when designed casually.

A button that says:

Request access

can become:

privilege escalation as a feature

The correct model is not “user asks, admin approves”.

The correct model is:

A denied subject requests a specific capability on a specific resource for a specific purpose and duration.
A policy-aware workflow routes that request to legitimate approvers.
The system grants the minimum access required, records why, expires it, and keeps the frontend projection synchronized.

This part builds the production model.


1. Access request is part of authorization

Access request is often treated as workflow UX.

That is incomplete.

It is authorization lifecycle management.

The lifecycle is:

denial -> explanation -> request -> review -> grant -> use -> expire/revoke -> audit/review

The React app participates in all stages:

- rendering request CTA after denial
- collecting reason/scope/duration
- showing request status
- preventing duplicate requests
- reflecting approved grants
- expiring stale UI
- guiding approvers
- showing audit trail

But the React app must not decide who gets access.

It only projects workflow state.


2. The request target must be precise

Bad request:

Please give me access.

Better request:

Subject: user_123
Action: case.approve
Resource: case_456
Tenant: tenant_789
Reason: Need to cover reviewer on approved leave
Duration: 2 days
Context: escalation queue Q3

A request without precise target is hard to approve safely.

Model it explicitly:

type AccessRequestTarget = {
  tenantId: string;
  resourceType: string;
  resourceId?: string;
  action: string;
  scope:
    | 'single_resource'
    | 'resource_collection'
    | 'tenant_role'
    | 'project_role'
    | 'temporary_delegation';
};

type AccessRequest = {
  id: string;
  requesterId: string;
  target: AccessRequestTarget;
  reason: string;
  requestedDuration?: string;
  status: AccessRequestStatus;
  createdAt: string;
  expiresAt?: string;
};

type AccessRequestStatus =
  | 'draft'
  | 'submitted'
  | 'needs_more_info'
  | 'approved'
  | 'denied'
  | 'expired'
  | 'cancelled'
  | 'grant_active'
  | 'grant_expired'
  | 'grant_revoked';

Access request target should use the same action vocabulary as authorization.

Do not maintain separate words for approval UI and policy engine.


3. Request access starts from a denial

The best request flow starts from a concrete denial.

Example decision:

type PermissionDecision = {
  allowed: false;
  code: 'DENIED';
  reason:
    | 'MISSING_PERMISSION'
    | 'NOT_RESOURCE_MEMBER'
    | 'WORKFLOW_ROLE_REQUIRED'
    | 'TENANT_ROLE_REQUIRED'
    | 'APPROVAL_REQUIRED';
  requestable: boolean;
  requestTemplate?: AccessRequestTemplate;
  correlationId: string;
};

When the user is denied, the server can say whether request is allowed.

{
  "allowed": false,
  "reason": "WORKFLOW_ROLE_REQUIRED",
  "requestable": true,
  "requestTemplate": {
    "action": "case.approve",
    "resourceType": "case",
    "resourceId": "case_456",
    "suggestedDuration": "P2D",
    "requiredFields": ["reason", "managerId"]
  }
}

React should not infer requestability from UI state.

Requestability is policy too.


4. Request state machine

Access request is a workflow.

Model it as state.

Do not collapse this to pending | approved | denied too early.

The missing states are where bugs live.


5. Request form design

A request form should collect minimum justification.

Common fields:

- action/resource being requested
- reason/business justification
- duration
- urgency
- approver, when user-selected approver is allowed
- ticket/case/change request reference
- whether access is temporary or permanent

Example:

type AccessRequestFormProps = {
  template: AccessRequestTemplate;
  onSubmit: (input: SubmitAccessRequestInput) => Promise<void>;
};

export function AccessRequestForm({ template, onSubmit }: AccessRequestFormProps) {
  const [reason, setReason] = useState('');
  const [duration, setDuration] = useState(template.suggestedDuration ?? 'P1D');

  const valid = reason.trim().length >= 20;

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void onSubmit({
          action: template.action,
          resourceType: template.resourceType,
          resourceId: template.resourceId,
          reason,
          duration,
        });
      }}
    >
      <p>
        Requesting <strong>{template.action}</strong> on{' '}
        <strong>{template.resourceType}</strong>
      </p>

      <label>
        Business reason
        <textarea value={reason} onChange={(e) => setReason(e.target.value)} required minLength={20} />
      </label>

      <label>
        Duration
        <select value={duration} onChange={(e) => setDuration(e.target.value)}>
          <option value="PT4H">4 hours</option>
          <option value="P1D">1 day</option>
          <option value="P2D">2 days</option>
          <option value="P7D">7 days</option>
        </select>
      </label>

      <button disabled={!valid}>Submit request</button>
    </form>
  );
}

The UI makes safe requests easy.

The server makes unsafe requests impossible.


6. Server-side validation

The server should validate:

1. Requester is authenticated.
2. Request target exists, or target existence can be safely hidden.
3. Requester belongs to the tenant/context.
4. Requested action is requestable.
5. Requested scope is allowed for requester.
6. Requested duration does not exceed policy.
7. Reason satisfies policy.
8. Duplicate request policy is enforced.
9. Approver routing is computed server-side.
10. Audit event is written.

Pseudocode:

async function submitAccessRequest(ctx: RequestContext, input: SubmitAccessRequestInput) {
  const target = await resolveRequestTarget(input.target);

  const requestability = await policy.check({
    subject: ctx.subject,
    action: 'access_request.submit',
    resource: target,
    context: {
      requestedAction: input.action,
      requestedDuration: input.duration,
      tenantId: ctx.tenantId,
    },
  });

  if (!requestability.allowed) {
    throw forbidden(requestability.reason);
  }

  const route = await approverRouting.compute({
    requester: ctx.subject,
    target,
    requestedAction: input.action,
    duration: input.duration,
  });

  const request = await accessRequests.create({
    requesterId: ctx.subject.id,
    tenantId: ctx.tenantId,
    target,
    requestedAction: input.action,
    duration: input.duration,
    reason: input.reason,
    approverRoute: route,
  });

  await audit.write({
    type: 'ACCESS_REQUEST_SUBMITTED',
    requestId: request.id,
    requesterId: ctx.subject.id,
    tenantId: ctx.tenantId,
    target,
    requestedAction: input.action,
  });

  return request;
}

Never trust frontend-selected approver blindly.

Approver routing is authorization logic.


7. Approver routing

Who can approve access?

Naive answer:

admin

Better answer:

An approver must have authority over the resource, must not violate separation of duties,
must not be the requester for self-approval, and must be valid for the requested action/scope/duration.

Routing examples:

project document edit -> project owner
case approval permission -> case supervisor
tenant role grant -> tenant admin with grant-role capability
sensitive export -> data protection officer + manager
production break-glass -> security approver + incident commander

Routing result:

type ApproverRoute = {
  mode: 'single' | 'any_of' | 'all_of' | 'ordered';
  steps: Array<{
    id: string;
    approverType: 'user' | 'role' | 'group' | 'relationship';
    approverRef: string;
    required: boolean;
  }>;
};

React can display the route.

But the server computes and enforces it.


8. Separation of duties

Access request workflows must prevent self-approval.

Rules:

- requester cannot approve own request
- delegate cannot approve request that grants them direct benefit
- approver must not be subordinate when policy forbids it
- maker/checker workflows require different identities
- emergency break-glass requires after-the-fact review

Decision shape:

type ApprovalDecision = {
  allowed: boolean;
  reason?:
    | 'NOT_APPROVER'
    | 'SELF_APPROVAL_DENIED'
    | 'SEPARATION_OF_DUTIES_DENIED'
    | 'REQUEST_ALREADY_RESOLVED'
    | 'POLICY_VERSION_CHANGED';
};

UI should handle these reasons clearly.

You cannot approve this request because you are the requester.

But for sensitive resource existence, use safer wording.


9. Temporary grants

Most approved requests should create temporary grants.

Permanent grants should be exceptional.

Temporary grant model:

type TemporaryGrant = {
  id: string;
  subjectId: string;
  tenantId: string;
  action: string;
  resourceType: string;
  resourceId?: string;
  sourceRequestId: string;
  grantedBy: string;
  reason: string;
  startsAt: string;
  expiresAt: string;
  status: 'active' | 'expired' | 'revoked';
};

Grant creation should be transactional with approval:

approve request
create grant
write audit event
invalidate permission projection
notify requester

Do not approve the workflow and forget to update permission caches.


10. Expiry semantics

Expiry must be enforced by the authorization service.

React may show countdowns.

React may warn users.

React may revalidate.

But React cannot enforce expiry.

Frontend projection:

{
  "temporaryGrants": [
    {
      "id": "grant_123",
      "action": "case.approve",
      "resourceType": "case",
      "resourceId": "case_456",
      "expiresAt": "2026-07-10T10:00:00Z"
    }
  ]
}

UI pattern:

function TemporaryGrantNotice({ grant }: { grant: TemporaryGrant }) {
  return (
    <aside role="status">
      Temporary access active for {grant.action}. Expires {formatRelative(grant.expiresAt)}.
    </aside>
  );
}

On expiry:

- permission endpoint stops returning allowed action
- API rejects action
- frontend invalidates permission cache
- active screen shows expired grant recovery

11. Revocation

Access must be revocable before expiry.

Revocation triggers:

- requester no longer needs access
- approver revokes
- manager changes
- resource state changes
- user leaves tenant
- role changes
- incident response
- policy version change

Revocation endpoint:

async function revokeTemporaryGrant(ctx: RequestContext, grantId: string, reason: string) {
  const grant = await grants.get(grantId);

  await policy.enforce({
    subject: ctx.subject,
    action: 'temporary_grant.revoke',
    resource: grant,
    context: { tenantId: ctx.tenantId },
  });

  await grants.revoke(grantId, { revokedBy: ctx.subject.id, reason });
  await permissionInvalidation.publish({ subjectId: grant.subjectId, tenantId: grant.tenantId });
  await audit.write({ type: 'TEMPORARY_GRANT_REVOKED', grantId, reason });
}

React should show revoked state explicitly.

Do not leave an approved request looking active after revocation.


12. Duplicate request policy

Users will click request access repeatedly if nothing happens.

Decide duplicate behavior:

same requester + same action + same resource + pending request -> show existing request
same requester + same action + same resource + denied request -> allow appeal or block cooldown
same requester + same action + same resource + active grant -> show active grant
same resource but broader scope -> require explicit escalation

Frontend helper:

type ExistingAccessRequestResult =
  | { kind: 'none' }
  | { kind: 'pending'; requestId: string }
  | { kind: 'active_grant'; grantId: string; expiresAt: string }
  | { kind: 'cooldown'; retryAfter: string };

Use idempotency keys for submit.

await api.post('/access-requests', input, {
  headers: { 'Idempotency-Key': crypto.randomUUID() },
});

Duplicate handling is both UX and abuse prevention.


13. Request status UI

The requester needs status.

Status card:

function AccessRequestStatusCard({ request }: { request: AccessRequest }) {
  return (
    <section>
      <h3>Access request</h3>
      <dl>
        <dt>Status</dt>
        <dd>{request.status}</dd>
        <dt>Action</dt>
        <dd>{request.target.action}</dd>
        <dt>Resource</dt>
        <dd>{request.target.resourceType}</dd>
        <dt>Requested at</dt>
        <dd>{formatTime(request.createdAt)}</dd>
      </dl>
    </section>
  );
}

Important states:

submitted -> waiting for approver
needs_more_info -> requester action needed
approved -> grant may be active or pending propagation
denied -> show safe denial reason
expired -> request no longer reviewable
grant_active -> show duration/revoke option
grant_expired -> show request again if policy allows

Avoid vague “pending” forever.


14. Approver inbox

Approvers need enough context to decide safely.

Show:

- requester identity
- requested action
- resource summary
- scope
- duration
- reason
- risk level
- existing requester roles/grants
- recent related requests
- conflict-of-interest warnings
- policy recommendation
- audit history link

Do not show data the approver is not allowed to see.

Approver UI is also authorization-gated.

function ApprovalActions({ request }: { request: AccessRequest }) {
  const approve = useCan('access_request.approve', request);
  const deny = useCan('access_request.deny', request);

  return (
    <div>
      <Button disabled={!approve.allowed}>Approve</Button>
      <Button disabled={!deny.allowed}>Deny</Button>
    </div>
  );
}

Again: server enforces.


15. Approval action design

Approval should not be a one-click accident for sensitive access.

For low-risk:

Approve -> grant temporary access

For high-risk:

Approve -> confirm scope/duration -> optional step-up -> grant

Approval command:

type ApproveAccessRequestInput = {
  requestId: string;
  approvedScope: AccessRequestTarget;
  approvedDuration: string;
  approverComment?: string;
  idempotencyKey: string;
};

Server checks:

- request is still pending
- approver is authorized
- approved scope does not exceed requested scope unless escalation allowed
- approved duration does not exceed policy
- separation of duties holds
- policy version is current

Approval is not just setting status.

Approval creates authority.


16. Denial design

Denying request should require a reason for audit and user feedback.

type DenyAccessRequestInput = {
  requestId: string;
  reasonCode:
    | 'BUSINESS_JUSTIFICATION_INSUFFICIENT'
    | 'SCOPE_TOO_BROAD'
    | 'NOT_NEEDED_FOR_ROLE'
    | 'CONFLICT_OF_INTEREST'
    | 'POLICY_DENIED'
    | 'OTHER';
  comment?: string;
};

Public message may differ from internal reason.

Requester sees:

Denied: business justification was insufficient.

Audit sees:

Approver denied because requester is under review team conflict policy.

Separate safe user feedback from internal policy trace.


17. Needs more information

Binary approve/deny is not enough.

Approvers often need clarification.

State:

needs_more_info

Flow:

approver asks question
requester gets task
requester responds
request returns to submitted
SLA timer may pause or continue depending on policy

Model comments as first-class.

type AccessRequestComment = {
  id: string;
  requestId: string;
  authorId: string;
  visibility: 'requester_and_approvers' | 'internal_only';
  body: string;
  createdAt: string;
};

Do not put internal-only comments in requester payload.


18. Access request notification

Users need notification when status changes.

Channels:

- in-app notification
- email
- Slack/Teams, if enterprise integration exists
- task inbox
- audit feed

Frontend should update via:

- polling
- invalidation after mutation
- WebSocket/SSE event
- notification click revalidation

Event:

type AccessRequestEvent =
  | { type: 'ACCESS_REQUEST_APPROVED'; requestId: string }
  | { type: 'ACCESS_REQUEST_DENIED'; requestId: string }
  | { type: 'ACCESS_REQUEST_NEEDS_MORE_INFO'; requestId: string }
  | { type: 'TEMPORARY_GRANT_EXPIRED'; grantId: string }
  | { type: 'TEMPORARY_GRANT_REVOKED'; grantId: string };

After receiving event, invalidate permission projection.


19. Permission cache invalidation

Access request approval changes authorization.

The frontend must update.

On grant created:

- invalidate /session or /permissions
- invalidate affected resource query
- invalidate route loader data
- update navigation/menu if new route becomes visible
- update pending request status

With TanStack Query style keys:

await queryClient.invalidateQueries({ queryKey: ['permissions', tenantId, userId] });
await queryClient.invalidateQueries({ queryKey: ['access-requests', userId] });
await queryClient.invalidateQueries({ queryKey: ['resource', resourceType, resourceId] });

With router data:

router.revalidate();

Do not require full logout/login to pick up a grant.

That signals weak permission invalidation design.


20. Access request and resource lifecycle

Resources change while requests are pending.

Examples:

- case moved to closed state
- document deleted
- project archived
- requester removed from tenant
- approver lost authority
- policy changed

Server should re-evaluate on approval.

request submitted under policy v3
approval attempted under policy v4
server re-checks requestability and approver authority
server may require re-submit or deny

UI state:

type RequestReviewability =
  | { reviewable: true }
  | { reviewable: false; reason: 'RESOURCE_STATE_CHANGED' | 'POLICY_CHANGED' | 'REQUESTER_NO_LONGER_ELIGIBLE' };

Never approve stale authority blindly.


21. Scope narrowing and widening

Approvers may approve less than requested.

Example:

requested: edit all project documents for 7 days
approved: edit document doc_123 for 1 day

Scope narrowing is usually safe.

Scope widening is dangerous.

Policy:

approve narrower scope -> allowed if approver has authority
approve broader scope -> require new request or explicit escalation policy

UI should make final grant clear:

Approved scope differs from requested scope.
You now have access only to Document A until July 10.

Do not hide scope changes.


22. Emergency access / break-glass

Sometimes access is needed immediately.

Break-glass is not normal request access.

Break-glass properties:

- strong step-up authentication
- explicit emergency reason
- short expiry
- broad audit
- immediate alert
- after-the-fact review
- limited set of users/actions

State machine:

React should show break-glass mode with unmistakable UI.

Emergency access active. All actions are audited and require post-review.

Do not bury break-glass behind the same “request access” button.


23. Delegation

Delegation is a special access request.

A user delegates authority to another user.

Examples:

- manager delegates approval while on leave
- case owner delegates edit to backup investigator
- tenant admin delegates billing read access to finance user

Delegation model:

type Delegation = {
  id: string;
  delegatorId: string;
  delegateeId: string;
  tenantId: string;
  actions: string[];
  resourceScope: AccessRequestTarget;
  startsAt: string;
  expiresAt: string;
  reason: string;
  status: 'scheduled' | 'active' | 'expired' | 'revoked';
};

Rules:

- delegator can only delegate authority they actually hold
- some actions are non-delegable
- delegation should be temporary by default
- delegatee actions must audit delegator context when relevant
- separation of duties can block delegation

React should distinguish:

You have delegated access from Maya Chen until Friday.

Not:

You are Maya Chen.

Delegation is not impersonation.


24. Access request vs role request vs entitlement request

Not all requests are equal.

Access request
  specific action/resource permission

Role request
  membership in a role, often broad

Entitlement request
  plan/product capability, often billing/commercial

Delegation request
  temporary authority from another user

Break-glass request
  emergency access with post-review

React should not render one generic form for all.

Use request type:

type RequestKind =
  | 'resource_access'
  | 'role_membership'
  | 'entitlement'
  | 'delegation'
  | 'break_glass';

Different kinds have different approvers, risk, duration, audit, and revocation semantics.


25. Access review integration

Access requests create grants.

Grants need review.

Periodic access review answers:

Who has access?
Why?
Who approved it?
When does it expire?
Was it used?
Is it still needed?

Grant should retain source request:

type GrantProvenance = {
  source: 'access_request' | 'role_assignment' | 'delegation' | 'break_glass' | 'system_migration';
  sourceId: string;
  approvedBy?: string;
  reason?: string;
};

React admin UI should show provenance.

Granted via Access Request AR-123, approved by Jordan Lee, expires in 2 days.

Without provenance, later cleanup is guesswork.


26. Audit log design

Minimum audit events:

ACCESS_REQUEST_SUBMITTED
ACCESS_REQUEST_UPDATED
ACCESS_REQUEST_CANCELLED
ACCESS_REQUEST_NEEDS_MORE_INFO
ACCESS_REQUEST_APPROVED
ACCESS_REQUEST_DENIED
TEMPORARY_GRANT_CREATED
TEMPORARY_GRANT_USED
TEMPORARY_GRANT_EXPIRED
TEMPORARY_GRANT_REVOKED
BREAK_GLASS_ACTIVATED
BREAK_GLASS_REVIEWED
DELEGATION_CREATED
DELEGATION_REVOKED

Audit event shape:

type AccessWorkflowAuditEvent = {
  type: string;
  timestamp: string;
  requestId?: string;
  grantId?: string;
  tenantId: string;
  actorId: string;
  requesterId?: string;
  approverId?: string;
  subjectId?: string;
  action?: string;
  resourceType?: string;
  resourceId?: string;
  decision?: 'approved' | 'denied' | 'revoked' | 'expired';
  reasonCode?: string;
  requestIdempotencyKey?: string;
  correlationId: string;
};

Audit is not optional.

Access workflows exist to explain authority.

If they do not produce reliable audit, they fail their own purpose.


27. Frontend route model

Access request screens usually include:

/request-access/new
/request-access/:requestId
/request-access/inbox
/request-access/history
/admin/access/grants
/admin/access/reviews

Route metadata:

type RouteHandle = {
  requiredAction: string;
  resource?: string;
};

export const handle: RouteHandle = {
  requiredAction: 'access_request.review',
  resource: 'tenant',
};

Loader-level checks:

export async function loader({ request, params }: LoaderArgs) {
  const session = await requireSession(request);
  const accessRequest = await accessRequests.get(params.requestId);

  await authorize(session, 'access_request.view', accessRequest);

  return { accessRequest };
}

Approver inbox must not list requests the viewer cannot review.


28. API contract

Useful endpoints:

GET    /access/request-template?action=&resourceType=&resourceId=
POST   /access/requests
GET    /access/requests/:id
GET    /access/requests?role=requester|approver
POST   /access/requests/:id/cancel
POST   /access/requests/:id/approve
POST   /access/requests/:id/deny
POST   /access/requests/:id/comment
GET    /access/grants
POST   /access/grants/:id/revoke

Request template endpoint is important.

It lets server tell React:

- whether request is possible
- what fields are required
- maximum duration
- suggested approvers, if any
- safe denial copy

Do not hardcode request rules in the React form.


29. Request template

Template shape:

type AccessRequestTemplate = {
  requestable: boolean;
  nonRequestableReason?: string;
  action: string;
  resourceType: string;
  resourceId?: string;
  maxDuration: string;
  suggestedDuration: string;
  requiresReason: boolean;
  requiresTicket: boolean;
  requiredFields: Array<'reason' | 'ticketId' | 'duration' | 'managerId'>;
  approverHint?: string;
  riskLevel: 'low' | 'medium' | 'high';
};

React flow:

user clicks Request Access
React fetches request template
if requestable=false show safe message
if requestable=true render form from template
submit request
show request status

This keeps policy close to the server.


30. Access request from hidden actions

If you hide actions the user cannot perform, how do they request access?

There are two patterns.

Pattern A — Hide non-requestable, show requestable

If action is denied and requestable, render disabled/request CTA.
If action is denied and not requestable, hide it.

Pattern B — Dedicated access center

User opens Access Center and requests known roles/resources.

Use both for mature apps.

Inline request is best for concrete task.

Access center is best for planned access.


31. Request access CTA

Component:

type RequestAccessButtonProps = {
  decision: PermissionDecision;
  onRequest: (template: AccessRequestTemplate) => void;
};

function RequestAccessButton({ decision, onRequest }: RequestAccessButtonProps) {
  if (decision.allowed) return null;
  if (!decision.requestable || !decision.requestTemplate) return null;

  return (
    <Button onClick={() => onRequest(decision.requestTemplate)}>
      Request access
    </Button>
  );
}

Use decision-provided template.

Do not build access requests from arbitrary user-entered action strings.


32. Preventing privilege escalation

Access request workflows create a new attack surface.

Controls:

- deny-by-default requestability
- server-computed approver route
- no self-approval
- no scope widening without explicit escalation
- duration caps
- sensitive actions require step-up
- duplicate/cooldown policy
- audit every transition
- request approval re-checks current policy
- grants expire by default
- grant usage is logged
- revoke on tenant membership loss

The system must be safe even if a user tampers with the form payload.

Example tampering:

{
  "action": "tenant.owner.transfer",
  "duration": "P3650D",
  "resourceType": "tenant",
  "resourceId": "tenant_important"
}

Server should reject because action/scope/duration are not requestable by that user.


33. Multi-tenant considerations

Access request must be tenant-scoped.

Every request includes tenant.

Every approver route is computed within tenant.

Every grant is tenant-bound.

Every cache key includes tenant.

const accessRequestKey = ['access-request', tenantId, requestId];
const approverInboxKey = ['access-request-inbox', tenantId, viewerId];

If the user switches tenant:

- clear draft request forms
- invalidate request templates
- hide pending requests from previous tenant
- reload permission projection

Never let an approval from Tenant A create grant in Tenant B.


34. Regulated case management example

Suppose a user tries to approve a case.

They are denied because only assigned reviewers can approve.

Decision:

{
  "allowed": false,
  "reason": "WORKFLOW_ROLE_REQUIRED",
  "requestable": true,
  "requestTemplate": {
    "action": "case.approve",
    "resourceType": "case",
    "resourceId": "case_123",
    "suggestedDuration": "P1D",
    "maxDuration": "P3D",
    "requiresReason": true,
    "riskLevel": "high"
  }
}

Request:

I need temporary reviewer access for case_123 because the assigned reviewer is unavailable and the SLA expires today.

Approver route:

case supervisor + compliance reviewer

Grant:

case.approve on case_123, expires in 24 hours

Audit:

request submitted -> approved by supervisor -> approved by compliance -> temporary grant created -> case approved -> grant expired

This is defensible.

A broad role grant would not be.


35. Request access and workflow state

Some access only makes sense in certain resource states.

Example:

case.approve is requestable only when case.status = READY_FOR_REVIEW
case.editEvidence is not requestable after case.status = CLOSED
document.publish is requestable only from DRAFT

Request template should include resource state.

type RequestTemplateContext = {
  resourceState: string;
  requestableUntil?: string;
  stateReason?: string;
};

On approval, server re-checks state.

If state changed, request becomes not reviewable.

React shows:

This request can no longer be approved because the case has moved to Closed.

36. UX for denied access request

Denial should not be a dead end.

Possible next states:

- user can edit and resubmit with better reason
- user can appeal
- user can request narrower scope
- user can ask resource owner directly
- user cannot request again due to policy

Response:

type DeniedRequestProjection = {
  status: 'denied';
  publicReason: string;
  nextActions: Array<'resubmit' | 'appeal' | 'request_narrower_scope' | 'none'>;
  cooldownUntil?: string;
};

Do not always show “Try again”.

Sometimes denial is final.


37. Access request abuse controls

Users can spam requests.

Controls:

- rate limit request submissions
- deduplicate pending requests
- require reason length/quality for high-risk access
- cooldown after repeated denial
- notify managers only for valid requestable targets
- hide approver identities if exposing them creates pressure
- audit repeated broad-scope attempts

Frontend should handle rate limit gracefully:

You have submitted several similar requests. Please wait before trying again.

Do not expose internal abuse scores.


38. Testing matrix

Request creation
[ ] denied permission with requestable=true shows Request Access CTA
[ ] denied permission with requestable=false does not show CTA
[ ] request template is loaded from server
[ ] tampered action is rejected server-side
[ ] duration over max is rejected server-side
[ ] duplicate request returns existing pending request
[ ] request reason is required when policy requires it

Approval
[ ] unauthorized viewer cannot see approver inbox item
[ ] approver can approve valid request
[ ] requester cannot self-approve
[ ] approver cannot widen scope unless policy allows
[ ] approval re-checks current resource state
[ ] approved request creates temporary grant
[ ] approval invalidates permission cache

Grant lifecycle
[ ] temporary grant allows target action
[ ] grant expires server-side
[ ] expired grant removes UI capability
[ ] revoked grant removes UI capability
[ ] tenant switch clears request/grant projection

Audit
[ ] request submission is audited
[ ] approval/denial is audited
[ ] grant creation/usage/expiry/revocation is audited
[ ] audit event includes requester, approver, action, resource, tenant, reason

UX
[ ] requester sees pending status
[ ] requester sees needs-more-info state
[ ] approver sees risk/scope/duration
[ ] denial shows safe reason
[ ] break-glass mode shows unmistakable warning

Test workflows as workflows.

Do not only test buttons.


39. Observability

Useful metrics:

access_request.submitted.count
access_request.approved.count
access_request.denied.count
access_request.needs_more_info.count
access_request.time_to_approval.seconds
access_request.duplicate.count
temporary_grant.created.count
temporary_grant.expired.count
temporary_grant.revoked.count
temporary_grant.used.count
break_glass.activated.count
access_request.self_approval_denied.count
access_request.scope_widen_denied.count

Useful alerts:

- spike in high-risk access requests
- repeated broad-scope requests by same requester
- grants not expiring
- high number of break-glass activations
- approval by unusual approver
- grant used after policy should have revoked it

Authorization workflows require operational visibility.


40. Anti-pattern catalog

Avoid these:

- generic “request access” without action/resource/scope
- letting requester choose any approver without server validation
- approving broad roles when narrow grant would work
- permanent grants by default
- no expiry
- no self-approval prevention
- no separation-of-duties rules
- no policy re-check at approval time
- no cache invalidation after grant
- no audit provenance
- no revocation
- no duplicate handling
- request UI hardcodes requestability instead of using server template
- hiding denial reason so user cannot recover
- exposing internal policy trace to requester

If your request workflow makes access easier to obtain than the original policy intended, it is not recovery.

It is a bypass.


41. Design checklist

Request model
[ ] Request target includes subject/action/resource/context/tenant.
[ ] Requestability is decided server-side.
[ ] Request template comes from server.
[ ] Request includes reason and duration where required.
[ ] Duplicate request behavior is defined.
[ ] Request state machine includes needs_more_info, expired, cancelled, revoked.

Approval model
[ ] Approver route is computed server-side.
[ ] Self-approval is blocked.
[ ] Separation of duties is enforced.
[ ] Approval re-checks current resource/policy state.
[ ] Scope widening is blocked or explicitly escalated.
[ ] High-risk approval requires step-up.

Grant model
[ ] Grants are temporary by default.
[ ] Grants include provenance.
[ ] Grant expiry is enforced server-side.
[ ] Grants are revocable.
[ ] Permission cache invalidates on grant create/revoke/expire.
[ ] Grant use is auditable.

Frontend
[ ] Denied requestable actions show Request Access CTA.
[ ] Non-requestable actions do not create false hope.
[ ] Requester can see status.
[ ] Approver gets enough context but not unauthorized data.
[ ] Tenant switch clears relevant request state.
[ ] Break-glass mode is visually distinct.

Audit and ops
[ ] Every transition creates audit event.
[ ] Metrics exist for request/approval/grant lifecycle.
[ ] Alerts exist for suspicious request and grant patterns.

42. Final mental model

Access request is not a convenience layer.

It is the recovery path of authorization.

A good workflow preserves least privilege:

denied action
  -> precise request
  -> legitimate approver
  -> narrow temporary grant
  -> synchronized permission projection
  -> expiry/revocation
  -> audit and review

A bad workflow destroys least privilege:

denied action
  -> broad role request
  -> admin approves casually
  -> permanent access
  -> no audit provenance

React's role is to make the safe path natural.

The server's role is to make unsafe paths impossible.

When access request workflows are done well, denial becomes productive without becoming porous.

That is the mark of a mature authorization system.


43. References

  • OWASP Authorization Cheat Sheet — least privilege, deny-by-default, validate permissions on every request.
  • OWASP Logging Cheat Sheet — security event logging and attributes.
  • NIST SP 800-53 Rev. 5 AC-2 and AC-6 — account management and least privilege control families.
  • NIST SP 800-162 — ABAC concepts for subject/resource/action/environment attributes.
  • Previous parts in this series: Part 041 Authorization Mental Model, Part 048 Frontend Permission Contract, Part 049 Permission Cache Invalidation, Part 058 Permission Reason and User Feedback, Part 059 Admin Impersonation UI.
Lesson Recap

You just completed lesson 60 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.