Time-Limited Access
Time-limited access provisioning creates accounts with built-in expiry dates for personnel whose engagement has a defined end point. You provision these accounts for volunteers, interns, short-term consultants, secondees, and surge staff deployed during emergency responses. The procedures in this task ensure that temporary personnel receive appropriate access for their role duration while automated expiry prevents orphaned accounts from accumulating.
Prerequisites
| Requirement | Detail |
|---|---|
| Access | Identity provider administrator role with user creation and group management permissions |
| Permissions | Ability to set account expiry dates and configure automated workflows |
| Tools | Access to identity provider admin console (Entra ID, Okta, Keycloak, or equivalent) |
| Information | Engagement end date, role assignment, manager/supervisor details, sponsoring department |
| Approval | Signed access request form with manager authorisation and HR confirmation of engagement dates |
| Time | 30 minutes for standard provisioning; 2 hours for complex role assignments |
Verify you have the required administrative access before proceeding:
# For Entra ID (Azure AD) - check role assignmentsaz role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) \ --query "[].roleDefinitionName" -o tsv# Required: User Administrator or higher
# For Keycloak - verify realm admin accesscurl -s -X GET "https://auth.example.org/admin/realms/production" \ -H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq '.realm'# Expected: realm name returned (not 403 Forbidden)Confirm the engagement details match HR records. Discrepancies between the access request and HR system create audit findings and complicate offboarding.
Procedure
Determine access duration
Identify the personnel category and retrieve the corresponding maximum access duration from your organisation’s policy. Standard durations provide consistency while allowing exceptions through documented approval:
Personnel category Standard duration Maximum extension Approval for extension Volunteer 90 days 90 days (once) Volunteer coordinator Intern 180 days 90 days (once) HR and supervisor Short-term consultant Contract end date 30 days Procurement and manager Secondee Secondment agreement end Per agreement HR director Surge staff (emergency) 90 days 90 days (renewable) Emergency coordinator Board/committee member Term end date Per governance rules Board secretary The access end date must not exceed the engagement end date recorded in HR systems. Where the engagement end date is uncertain (common for emergency surge deployments), set initial access for 90 days with documented review points.
Calculate the account expiry date by adding the appropriate duration to the start date. For existing personnel whose access you are formalising, use the current date as the start:
Start date: 2024-11-15Personnel category: InternStandard duration: 180 daysCalculated expiry: 2025-05-14Cross-check with HR engagement end: 2025-05-01Final expiry date: 2025-05-01 (earlier of calculated and HR date)Set expiry time to 23:59 in the user’s primary work timezone to avoid mid-day access termination during their final working day.
Provision the account
Create the user account with the expiry date configured at creation time. Setting expiry during initial provisioning prevents accounts from being created without time limits:
Entra ID (Azure AD):
Terminal window # Create user with account expiry$PasswordProfile = @{Password = (New-Guid).ToString() + "!Aa1"ForceChangePasswordNextSignIn = $true}New-MgUser -DisplayName "Jane Smith (Intern)" `-UserPrincipalName "jane.smith.intern@example.org" `-MailNickname "jane.smith.intern" `-AccountEnabled $true `-PasswordProfile $PasswordProfile `-Department "Programme Team" `-JobTitle "Programme Intern" `-EmployeeType "Intern" `-EmployeeHireDate "2024-11-15" `-AdditionalProperties @{"extension_accountExpires" = "2025-05-01T23:59:00Z"}Keycloak:
Terminal window # Create user with custom expiry attributecurl -X POST "https://auth.example.org/admin/realms/production/users" \-H "Authorization: Bearer $KEYCLOAK_TOKEN" \-H "Content-Type: application/json" \-d '{"username": "jane.smith.intern","email": "jane.smith.intern@example.org","firstName": "Jane","lastName": "Smith","enabled": true,"attributes": {"employeeType": ["Intern"],"department": ["Programme Team"],"accountExpires": ["2025-05-01T23:59:00Z"],"sponsor": ["m.jones@example.org"],"engagementId": ["INT-2024-0847"]},"credentials": [{"type": "password","value": "TemporaryP@ss1!","temporary": true}]}'Okta:
Terminal window curl -X POST "https://example.okta.com/api/v1/users?activate=true" \-H "Authorization: SSWS $OKTA_TOKEN" \-H "Content-Type: application/json" \-d '{"profile": {"firstName": "Jane","lastName": "Smith","email": "jane.smith.intern@example.org","login": "jane.smith.intern@example.org","department": "Programme Team","title": "Programme Intern","employeeType": "Intern","accountExpires": "2025-05-01"},"credentials": {"password": { "value": "TemporaryP@ss1!" }}}'Apply the naming convention that identifies temporary accounts. Consistent naming enables bulk queries, access reviews, and audit reporting:
Personnel category Username format Display name format Volunteer firstname.lastname.vol Firstname Lastname (Volunteer) Intern firstname.lastname.intern Firstname Lastname (Intern) Consultant firstname.lastname.ext Firstname Lastname (Consultant) Secondee firstname.lastname.sec Firstname Lastname (Secondee - Org) Surge staff firstname.lastname.surge Firstname Lastname (Surge) The suffix in usernames enables filtering. The parenthetical in display names ensures recipients of emails and meeting invitations recognise the sender’s temporary status.
Assign the user to groups that grant role-appropriate access. Temporary personnel receive access through dedicated groups with restricted permissions rather than the same groups used for permanent staff:
Terminal window # Entra ID - Add to temporary personnel groups$userId = (Get-MgUser -Filter "userPrincipalName eq 'jane.smith.intern@example.org'").Id# Base group for all temporary personnel (limited shared resources)Add-MgGroupMember -GroupId "a1b2c3d4-temp-personnel-group" -DirectoryObjectId $userId# Role-specific group (programme team read access)Add-MgGroupMember -GroupId "e5f6g7h8-programme-readonly" -DirectoryObjectId $userId# Time-limited group with dynamic expiry (if using dynamic groups)# This group auto-removes members based on accountExpires attributeTerminal window # Keycloak - Assign groupsUSER_ID=$(curl -s "https://auth.example.org/admin/realms/production/users?username=jane.smith.intern" \-H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq -r '.[0].id')# Get group IDGROUP_ID=$(curl -s "https://auth.example.org/admin/realms/production/groups?search=temp-programme-readonly" \-H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq -r '.[0].id')# Add user to groupcurl -X PUT "https://auth.example.org/admin/realms/production/users/$USER_ID/groups/$GROUP_ID" \-H "Authorization: Bearer $KEYCLOAK_TOKEN"Configure application-specific access where the identity provider group membership does not automatically provision application access. Some applications require direct provisioning:
Terminal window # Example: Add user to project management system via APIcurl -X POST "https://projects.example.org/api/v1/users" \-H "Authorization: Bearer $PROJECT_API_KEY" \-H "Content-Type: application/json" \-d '{"email": "jane.smith.intern@example.org","role": "viewer","projects": ["PRJ-2024-NUTRITION"],"expiresAt": "2025-05-01T23:59:00Z"}'Document all application-specific provisioning in the access request record. This documentation is essential for complete offboarding.
Configure automated expiry
Enable the automated account disablement workflow that triggers on the expiry date. This automation runs daily and disables accounts whose expiry date has passed:
Entra ID with Azure Automation:
Disable-ExpiredAccounts.ps1 # Schedule: Daily at 00:30 UTCConnect-MgGraph -Identity$today = Get-Date -Format "yyyy-MM-ddT00:00:00Z"# Find users with expired accountExpires attribute$expiredUsers = Get-MgUser -Filter "accountEnabled eq true" -All | Where-Object {$expires = $_.AdditionalProperties["extension_accountExpires"]$expires -and ([datetime]$expires -lt [datetime]$today)}foreach ($user in $expiredUsers) {# Disable the accountUpdate-MgUser -UserId $user.Id -AccountEnabled $false# Log the actionWrite-Output "Disabled expired account: $($user.UserPrincipalName) - Expired: $($user.AdditionalProperties['extension_accountExpires'])"# Send notification to sponsor/manager$sponsor = $user.Manager.Idif ($sponsor) {# Trigger notification Logic App or send email}}Keycloak with scheduled task:
/etc/cron.daily/disable-expired-keycloak-users #!/bin/bashKEYCLOAK_URL="https://auth.example.org"REALM="production"# AuthenticateTOKEN=$(curl -s -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \-d "grant_type=client_credentials" \-d "client_id=admin-cli" \-d "client_secret=$KEYCLOAK_CLIENT_SECRET" | jq -r '.access_token')TODAY=$(date -u +%Y-%m-%dT%H:%M:%SZ)# Get all enabled users with accountExpires attributeUSERS=$(curl -s "$KEYCLOAK_URL/admin/realms/$REALM/users?enabled=true&max=1000" \-H "Authorization: Bearer $TOKEN")echo "$USERS" | jq -c '.[]' | while read USER; doEXPIRES=$(echo "$USER" | jq -r '.attributes.accountExpires[0] // empty')if [[ -n "$EXPIRES" && "$EXPIRES" < "$TODAY" ]]; thenUSER_ID=$(echo "$USER" | jq -r '.id')USERNAME=$(echo "$USER" | jq -r '.username')# Disable usercurl -s -X PUT "$KEYCLOAK_URL/admin/realms/$REALM/users/$USER_ID" \-H "Authorization: Bearer $TOKEN" \-H "Content-Type: application/json" \-d '{"enabled": false}'echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Disabled expired user: $USERNAME (expired: $EXPIRES)" >> /var/log/keycloak-expiry.logfidoneConfigure advance warning notifications to alert sponsors, managers, and the user before account expiry. Notifications at 14 days, 7 days, and 1 day prevent surprise access termination:
Send-ExpiryWarnings.ps1 # Schedule: Daily at 08:00 local time$warningDays = @(14, 7, 1)foreach ($days in $warningDays) {$targetDate = (Get-Date).AddDays($days).ToString("yyyy-MM-dd")$expiringUsers = Get-MgUser -Filter "accountEnabled eq true" -All | Where-Object {$expires = $_.AdditionalProperties["extension_accountExpires"]$expires -and $expires.StartsWith($targetDate)}foreach ($user in $expiringUsers) {$expiryDate = $user.AdditionalProperties["extension_accountExpires"]$sponsor = $user.AdditionalProperties["sponsor"]# Send notification (via Logic App, Power Automate, or direct email)$notificationBody = @{recipient = $user.Mailcc = $sponsorsubject = "Access expiry notice: $days day(s) remaining"body = "Your account $($user.UserPrincipalName) will expire on $expiryDate. Contact your supervisor if an extension is required."}Invoke-RestMethod -Uri $notificationWebhookUrl -Method Post -Body ($notificationBody | ConvertTo-Json)}}
Process extension requests
Receive and validate extension requests before the account expires. Extensions require documented justification and appropriate approval:
Extension request validation checklist:[ ] Request received before expiry date (not retroactive)[ ] Justification provided (project continuation, delayed completion)[ ] HR confirms engagement extended or new contract in place[ ] Requested duration within policy maximum for personnel category[ ] Approver has authority for this personnel category[ ] Previous extensions documented (check extension count limit)Reject retroactive extension requests. Once an account expires, treat reactivation as a new provisioning request requiring full approval workflow.
Update the account expiry date after receiving documented approval. Record the extension in the account attributes for audit purposes:
Terminal window # Entra ID - Extend account$userId = (Get-MgUser -Filter "userPrincipalName eq 'jane.smith.intern@example.org'").Id$currentExpiry = (Get-MgUser -UserId $userId).AdditionalProperties["extension_accountExpires"]$newExpiry = "2025-08-01T23:59:00Z"Update-MgUser -UserId $userId -AdditionalProperties @{"extension_accountExpires" = $newExpiry"extension_extensionHistory" = "$currentExpiry|Extended to $newExpiry on $(Get-Date -Format 'yyyy-MM-dd') by admin@example.org|REQ-2025-0123"}Terminal window # Keycloak - Extend accountUSER_ID="user-uuid-here"curl -X PUT "https://auth.example.org/admin/realms/production/users/$USER_ID" \-H "Authorization: Bearer $KEYCLOAK_TOKEN" \-H "Content-Type: application/json" \-d '{"attributes": {"accountExpires": ["2025-08-01T23:59:00Z"],"extensionHistory": ["2025-05-01|Extended to 2025-08-01 on 2025-04-15|REQ-2025-0123"]}}'
Offboard expired accounts
Verify account disablement occurred as scheduled by checking the automated job logs and confirming the account state:
Terminal window # Verify account is disabled$user = Get-MgUser -Filter "userPrincipalName eq 'jane.smith.intern@example.org'" -Property accountEnabled,signInActivityWrite-Output "Account enabled: $($user.AccountEnabled)"Write-Output "Last sign-in: $($user.SignInActivity.LastSignInDateTime)"# Expected output for properly expired account:# Account enabled: False# Last sign-in: 2025-04-30T16:42:00Z (before expiry)Revoke application-specific access that was provisioned outside the identity provider. Applications with direct provisioning require direct deprovisioning:
Terminal window # Revoke access from project management systemcurl -X DELETE "https://projects.example.org/api/v1/users/jane.smith.intern@example.org" \-H "Authorization: Bearer $PROJECT_API_KEY"# Revoke access from file sharing platformcurl -X POST "https://files.example.org/api/users/jane.smith.intern@example.org/deactivate" \-H "Authorization: Bearer $FILES_API_KEY"Transfer or archive data owned by the departing user. Coordinate with the supervisor to identify data requiring retention:
Terminal window # Transfer OneDrive content to manager (Entra ID / Microsoft 365)$userId = (Get-MgUser -Filter "userPrincipalName eq 'jane.smith.intern@example.org'").Id$managerId = (Get-MgUser -Filter "userPrincipalName eq 'm.jones@example.org'").Id# Initiate OneDrive transfer (requires SharePoint admin)Start-MgUserOneDriveTransfer -UserId $userId -TargetUserId $managerIdDelete the account after the retention period (typically 30-90 days post-expiry). Deletion removes the account permanently; ensure all data transfer and audit requirements are complete:
Terminal window # Entra ID - Delete user after retention period$user = Get-MgUser -Filter "userPrincipalName eq 'jane.smith.intern@example.org'"# Verify account has been disabled for required retention period$disabledDate = $user.AdditionalProperties["extension_accountExpires"]$retentionDays = 30$deleteEligible = ([datetime]$disabledDate).AddDays($retentionDays) -lt (Get-Date)if ($deleteEligible) {Remove-MgUser -UserId $user.IdWrite-Output "Deleted user: $($user.UserPrincipalName)"} else {Write-Output "User not yet eligible for deletion. Retention period ends: $(([datetime]$disabledDate).AddDays($retentionDays))"}
Time-limited access lifecycle
The following diagram illustrates the complete lifecycle from provisioning through deletion:
+------------------+| || ACCESS REQUEST || SUBMITTED || |+--------+---------+ | | Validate engagement dates | Determine personnel category v+--------+---------+| || CALCULATE || EXPIRY DATE || |+--------+---------+ | | Create account with expiry | Assign temporary groups v+--------+---------+| || ACCOUNT || ACTIVE |<------------------------------------------+| | |+--------+---------+ | | | | Automated daily check | v |+--------+---------+ +------------------+ || | | | || EXPIRY CHECK +---->| 14-DAY WARNING | || | | NOTIFICATION | |+--------+---------+ +------------------+ | | | | Continue daily checks | v |+--------+---------+ +------------------+ || | | | +----------+ || EXPIRY CHECK +---->| 7-DAY WARNING +---->| EXTEND? +-+| | | NOTIFICATION | | REQUEST | | Yes (approved)+--------+---------+ +------------------+ +----+-----+ | | | | | No | | v v |+--------+---------+ +------------------+ +-----+------+| | | | | || EXPIRY CHECK +---->| 1-DAY WARNING +---->| ACCOUNT || | | NOTIFICATION | | EXPIRES |+--------+---------+ +------------------+ +-----+------+ | | Automated disable v +-----+------+ | | | ACCOUNT | | DISABLED | | | +-----+------+ | | Retention period | (30-90 days) v +-----+------+ | | | ACCOUNT | | DELETED | | | +------------+Figure 1: Time-limited access lifecycle showing automated expiry checks, warning notifications, and extension decision point
Extension approval workflow
Extension requests follow a defined approval path based on personnel category and requested duration:
+-------------------+ | | | EXTENSION | | REQUEST | | | +--------+----------+ | v +--------+----------+ | | | VALIDATE | | REQUEST | | | | - Before expiry? | | - Within policy? | | - HR confirmed? | | | +--------+----------+ | +--------------+--------------+ | | | Valid | Invalid v v +---------+----------+ +---------+----------+ | | | | | ROUTE TO | | REJECT WITH | | APPROVER | | EXPLANATION | | | | | +----+----------+----+ +--------------------+ | | | +------------------+ | | | Standard duration | Exceeds standard v v+--------+----------+ +---------+----------+| | | || LINE MANAGER | | LINE MANAGER || APPROVAL | | + HR APPROVAL || | | + DEPT HEAD |+--------+----------+ +---------+----------+ | | +-------------+---------------+ | v +--------+----------+ | | | UPDATE EXPIRY | | DATE | | | | Log extension | | history | | | +-------------------+Figure 2: Extension approval workflow with escalation path for non-standard durations
Verification
After provisioning a time-limited account, verify the configuration:
# Comprehensive verification script (Entra ID)$upn = "jane.smith.intern@example.org"$user = Get-MgUser -Filter "userPrincipalName eq '$upn'" -Property Id,DisplayName,AccountEnabled,UserPrincipalName,EmployeeType,Department,JobTitle,AdditionalProperties
Write-Output "=== Account Verification ==="Write-Output "UPN: $($user.UserPrincipalName)"Write-Output "Display Name: $($user.DisplayName)"Write-Output "Account Enabled: $($user.AccountEnabled)"Write-Output "Employee Type: $($user.EmployeeType)"Write-Output "Department: $($user.Department)"Write-Output "Expiry Date: $($user.AdditionalProperties['extension_accountExpires'])"Write-Output "Sponsor: $($user.AdditionalProperties['sponsor'])"
# Verify group membershipsWrite-Output "`n=== Group Memberships ==="Get-MgUserMemberOf -UserId $user.Id | ForEach-Object { $group = Get-MgGroup -GroupId $_.Id Write-Output "- $($group.DisplayName)"}
# Verify naming convention compliance$expectedSuffix = ".intern"if ($user.UserPrincipalName -like "*$expectedSuffix@*") { Write-Output "`n[PASS] Username follows naming convention"} else { Write-Output "`n[FAIL] Username does not follow naming convention"}
# Verify expiry is set and within policy$expiry = [datetime]$user.AdditionalProperties['extension_accountExpires']$maxDuration = 180 # days for interns$created = (Get-Date) # Approximate; use actual creation date in productionif ($expiry -le $created.AddDays($maxDuration)) { Write-Output "[PASS] Expiry date within policy maximum"} else { Write-Output "[FAIL] Expiry date exceeds policy maximum"}Expected output for correctly provisioned account:
=== Account Verification ===UPN: jane.smith.intern@example.orgDisplay Name: Jane Smith (Intern)Account Enabled: TrueEmployee Type: InternDepartment: Programme TeamExpiry Date: 2025-05-01T23:59:00ZSponsor: m.jones@example.org
=== Group Memberships ===- Temporary Personnel - All- Programme Team - Read Only
[PASS] Username follows naming convention[PASS] Expiry date within policy maximumVerify the automated expiry job is operational:
# Check last execution of expiry automation# Azure Automationaz automation job list --automation-account-name "identity-automation" \ --resource-group "identity-rg" \ --query "[?runbook.name=='Disable-ExpiredAccounts'].{Status:status,StartTime:startTime,EndTime:endTime}" \ --output table
# Expected: Recent successful execution within last 24 hours# STATUS STARTTIME ENDTIME# --------- ---------------------------- ----------------------------# Completed 2024-11-16T00:30:00.000000Z 2024-11-16T00:31:15.000000ZTest the expiry mechanism with a test account before relying on it for production:
# Create test account with expiry set to yesterday$testUpn = "test.expiry.$(Get-Random)@example.org"New-MgUser -DisplayName "Test Expiry Account" ` -UserPrincipalName $testUpn ` -MailNickname "test.expiry" ` -AccountEnabled $true ` -PasswordProfile @{Password="TestP@ss123!"; ForceChangePasswordNextSignIn=$true} ` -AdditionalProperties @{ "extension_accountExpires" = (Get-Date).AddDays(-1).ToString("yyyy-MM-ddTHH:mm:ssZ") }
# Wait for automation to run (or trigger manually)# Then verify account was disabledStart-Sleep -Seconds 60 # Or wait for scheduled run$testUser = Get-MgUser -Filter "userPrincipalName eq '$testUpn'"Write-Output "Test account enabled: $($testUser.AccountEnabled)"# Expected: False
# Clean up test accountRemove-MgUser -UserId $testUser.IdTroubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| Account not disabled after expiry date | Automation job failed or not running | Check automation job execution logs; verify job schedule is enabled; confirm job has appropriate permissions |
| User reports access loss before expected expiry | Timezone mismatch in expiry date | Verify expiry time includes timezone; set to 23:59 in user’s local timezone, not UTC midnight |
| Cannot find account to extend | Account already deleted after retention | Treat as new provisioning request; deleted accounts cannot be restored after permanent deletion |
| Extension request rejected despite approval | Extension count limit exceeded | Policy limits extensions; requires department head exception approval for additional extension |
| Sponsor not receiving expiry notifications | Sponsor attribute not set or incorrect | Verify sponsor attribute populated with valid email; check notification job logs |
| User cannot sign in despite active account | Account disabled by separate process | Check if user triggered account lockout policy; verify no conflicting conditional access policies |
| Automated job runs but no accounts processed | Query filter not matching accounts | Verify attribute name used for expiry matches configured attribute; test query manually |
| Group membership not removed at expiry | Using static groups instead of dynamic | Configure dynamic group rules based on accountEnabled status, or add group removal to expiry job |
| Application access persists after account disabled | Application uses cached session | Force session revocation in application; some applications require direct deprovisioning API call |
| Audit logs missing expiry actions | Automation running with insufficient logging | Add explicit logging to automation scripts; configure audit log retention |
| New account created without expiry date | Provisioning done outside standard process | Require expiry date as mandatory field; run weekly report of accounts without expiry |
| Multiple extension requests for same period | Confusion about current expiry date | Include current expiry date in all extension-related communications; display prominently in request form |
Automation verification
Confirm the automated components are functioning correctly:
# Verify cron job is scheduled (Linux/Keycloak)crontab -l | grep -i expir# Expected: 30 0 * * * /etc/cron.daily/disable-expired-keycloak-users
# Check cron execution loggrep "disable-expired" /var/log/cron# Expected: Recent execution entries
# Verify log file is being writtentail -20 /var/log/keycloak-expiry.log# Expected: Recent entries showing processed accounts# Verify Azure Automation runbook scheduleGet-AzAutomationScheduledRunbook -AutomationAccountName "identity-automation" ` -ResourceGroupName "identity-rg" | Where-Object {$_.RunbookName -like "*Expired*"}
# Expected output showing schedule details:# RunbookName ScheduleName# ----------- ------------# Disable-ExpiredAccounts Daily-0030-UTC# Send-ExpiryWarnings Daily-0800-Local