Handling authentication tokens for ArcGIS REST services
This guide is part of Parsing GeoJSON & Shapefile APIs, which covers the full workflow for fetching, validating, and persisting vector data from REST endpoints.
ArcGIS REST token authentication fails spatial ETL pipelines when scripts submit credentials on every request: the /generateToken endpoint imposes per-IP rate limits that trigger HTTP 429 responses under sustained load, and each credential POST adds 200β400 ms of latency per call. The correct pattern is to generate a short-lived token once, cache it in memory alongside its millisecond-epoch expiry timestamp, and proactively refresh it 120 seconds before it expires β before any in-flight spatial query hits an expired credential mid-stream.
Why expired or misconfigured tokens break spatial pipelines
Token failures are silent killers in automated ingestion. They do not raise immediate Python exceptions; instead, the ArcGIS REST endpoint returns a 200 OK response with a JSON body containing an error key. A pipeline that only checks HTTP status codes will log βsuccessβ while persisting zero features.
- Silent empty-result returns: A
498 Invalid Tokenor499 Token Requirederror inside a200body meansgeopandas.read_file()receives an empty feature collection rather than a geometry error, producing a zero-rowGeoDataFramethat silently propagates downstream. - Cascading join failures: Downstream spatial joins against an empty or incomplete dataset produce silent attribute nulls. If the join feeds a rasterization step, entire grid cells are dropped with no traceable error.
- Broken pagination: Paginated feature service queries (using
resultOffset) that span a token expiry boundary return the first page successfully but error silently on subsequent pages, delivering partial datasets. - Audit trail gaps: Without explicit token-lifecycle logging, on-call engineers see only a downstream anomaly β an unusually small file, an empty PostGIS table β with no record of when or why authentication lapsed.
Token lifecycle diagram
Version and environment compatibility
| Component | Minimum version | Notes |
|---|---|---|
| ArcGIS Server | 10.1 | Legacy 10.0 used /arcgis/tokens β adjust base_url accordingly |
| ArcGIS Online | Always current | OAuth 2.0 preferred for new apps; /sharing/rest/generateToken remains valid for scripted ETL |
| Python | 3.9+ | time.time() epoch arithmetic and type hints require no backports above 3.9 |
requests |
2.28+ | Session connection pooling and Retry adapter are stable across this range |
| Network | Outbound 443/tcp | Firewalls must allow HTTPS to *.arcgis.com or on-premises FQDNs |
Production-ready ArcGISTokenManager recipe
The function below is the single piece of code your pipeline needs. It encapsulates credential storage, epoch conversion, proactive refresh, and request dispatch inside a reusable manager class. Import it once per pipeline run and call request_service() instead of requests.get() directly.
import os
import time
import logging
from typing import Any, Dict, Optional
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
class ArcGISTokenManager:
"""
Manages ArcGIS REST token generation, caching, and proactive refresh.
Token expiry is returned by the server in milliseconds since Unix epoch.
Tokens are refreshed automatically when fewer than REFRESH_BUFFER seconds remain.
Usage:
mgr = ArcGISTokenManager.from_env("https://services.arcgis.com/YOUR_ORG")
data = mgr.request_service("https://services.arcgis.com/.../FeatureServer/0/query",
params={"where": "1=1", "outFields": "*"})
"""
REFRESH_BUFFER: int = 120 # seconds before expiry to trigger proactive renewal
def __init__(
self,
base_url: str,
username: str,
password: str,
referer: str = "https://geospatial-etl.com",
client: str = "requestip",
) -> None:
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.referer = referer
self.client = client
self._token: Optional[str] = None
self._expires_at: float = 0.0 # Unix seconds
# Reuse a single session for connection pooling across all requests
retry = Retry(
total=3,
backoff_factor=1.0,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"],
)
self._session = requests.Session()
self._session.mount("https://", HTTPAdapter(max_retries=retry))
@classmethod
def from_env(
cls,
base_url: str,
referer: str = "https://geospatial-etl.com",
client: str = "requestip",
) -> "ArcGISTokenManager":
"""Construct from ARCGIS_USERNAME / ARCGIS_PASSWORD environment variables."""
username = os.environ["ARCGIS_USERNAME"]
password = os.environ["ARCGIS_PASSWORD"]
return cls(base_url, username, password, referer=referer, client=client)
def _generate_token(self) -> str:
"""POST credentials to /sharing/rest/generateToken and cache the result."""
endpoint = f"{self.base_url}/sharing/rest/generateToken"
payload = {
"username": self.username,
"password": self.password,
"referer": self.referer,
"client": self.client,
"expiration": 60, # minutes; adjust to match your ArcGIS Server policy
"f": "json",
}
resp = self._session.post(endpoint, data=payload, timeout=15)
resp.raise_for_status()
data = resp.json()
# ArcGIS wraps auth errors in a 200 response body β always inspect
if "error" in data:
code = data["error"].get("code", "unknown")
msg = data["error"].get("message", "")
raise RuntimeError(
f"ArcGIS token generation failed [{code}]: {msg}"
)
self._token = data["token"]
# expires is milliseconds since Unix epoch β convert to seconds
self._expires_at = data["expires"] / 1000.0
logger.info(
"ArcGIS token acquired; expires in %.0f seconds",
self._expires_at - time.time(),
)
return self._token
def get_valid_token(self) -> str:
"""Return a cached token, refreshing proactively if within REFRESH_BUFFER."""
if not self._token or (self._expires_at - time.time()) < self.REFRESH_BUFFER:
return self._generate_token()
return self._token
def request_service(
self,
service_url: str,
params: Optional[Dict[str, Any]] = None,
method: str = "GET",
) -> Dict[str, Any]:
"""
Make an authenticated request to an ArcGIS REST endpoint.
The token is appended as a query parameter (required by many legacy
feature services) and as an Authorization header for modern endpoints.
"""
token = self.get_valid_token()
merged_params = {"f": "json", "token": token, **(params or {})}
headers = {"Referer": self.referer}
if method.upper() == "POST":
resp = self._session.post(
service_url, data=merged_params, headers=headers, timeout=30
)
else:
resp = self._session.get(
service_url, params=merged_params, headers=headers, timeout=30
)
resp.raise_for_status()
data = resp.json()
# Surface ArcGIS-level errors that arrive inside 200 responses
if "error" in data:
code = data["error"].get("code", "unknown")
msg = data["error"].get("message", "")
raise RuntimeError(f"ArcGIS service error [{code}]: {msg}")
return dataKey implementation notes
-
from_envclass method: Credentials are read fromARCGIS_USERNAMEandARCGIS_PASSWORDenvironment variables at instantiation, never from hard-coded strings. CI/CD systems and container orchestrators inject secrets this way without exposing them in source control or log output. -
Millisecond-to-second epoch conversion: The ArcGIS
generateTokenresponse returnsexpiresin milliseconds since the Unix epoch, not seconds. Dividing by1000.0is mandatory; treating the raw value as seconds makes every token appear to expire roughly 1,140 years in the future, disabling the refresh buffer entirely. -
Inspecting
200 OKbodies forerrorkeys: ArcGIS REST wraps authentication failures, service errors, and quota violations inside HTTP200responses. Any code that only checksraise_for_status()will silently treat a498 Invalid Tokenerror body as a success. Both_generate_token()andrequest_service()explicitly inspect the response JSON for"error"before returning. -
client=requestipvsclient=referer:requestipbinds the token to the originating IP address β straightforward for a single-server ETL host.refererinstead validates the HTTPRefererheader on every subsequent request; the value must match exactly (including protocol and trailing slash) what was submitted during token generation. Mismatches produce498errors that are hard to diagnose without side-by-side comparison of the registered and transmitted referer strings. -
expirationparameter scoping: Theexpirationfield (in minutes) requests a token lifetime from the server, but the server may cap it to the maximum allowed by the ArcGIS Server security policy. Always trust theexpiresvalue in the response, not the requested duration, when computing_expires_at. -
Single
requests.Sessionwith retry adapter: Reusing one session object across all token and service calls keeps the TCP connection pool alive, reducing per-request TLS handshake overhead by 100β300 ms on high-frequency pipelines. TheRetryadapter withbackoff_factor=1.0handles transient503and429responses without manualtime.sleep()calls.
Deployment checklist
Before scheduling this recipe in an automated pipeline, verify the following:
- Confirm
referermatches the allowed referrer registered under ArcGIS Server Manager β Security β Token Settings (or the equivalent ArcGIS Online app configuration). - Test that
ARCGIS_USERNAMEandARCGIS_PASSWORDare injected as environment variables β not present indocker inspect,ps aux, or application logs. - Run a 10-minute soak test against a dev feature service to confirm the 120-second refresh buffer triggers correctly before expiry.
- Add a log alert on
"ArcGIS token generation failed"messages so on-call engineers are notified within minutes of credential rotation breaking the pipeline. - Rotate base credentials at least quarterly and invalidate existing tokens via the ArcGIS Administrator API immediately after rotation.
Troubleshooting: ArcGIS error codes
| HTTP Status / Error Code | Root Cause | Fix |
|---|---|---|
200 body code: 498 Invalid Token |
Token expired or referer mismatch | Check _expires_at logic; compare referer values exactly |
200 body code: 499 Token Required |
Token parameter missing from request | Verify merged_params includes "token" key |
200 body code: 400 |
Malformed request parameters | Log merged_params (redact token) and inspect against feature service documentation |
HTTP 403 Forbidden |
IP blocked or user account lacks service access | Verify firewall egress, confirm user has View permissions on the service |
HTTP 429 Too Many Requests |
Token generation rate limit exceeded | Token caching is insufficient β check get_valid_token() is not called per-row |
Integration note
This recipe slots in as the authentication layer immediately before the vector fetch step in Parsing GeoJSON & Shapefile APIs. Instantiate ArcGISTokenManager once at pipeline startup, then pass mgr.request_service(feature_service_url, params=query_params) results directly to geopandas.GeoDataFrame.from_features(). When the feature service returns paginated results, the resultOffset loop calls request_service() on each page β the proactive refresh buffer ensures no page boundary coincides with a token expiry. For spatial queries that require bounding-box pre-filtering before the authenticated call, see Extracting Bounding Boxes from GeoJSON APIs for coordinate-order-safe extent computation.
Related
- Parsing GeoJSON & Shapefile APIs in Python β full ingestion workflow: fetch, validate, normalize, and persist vector data from REST endpoints
- Extracting Bounding Boxes from GeoJSON APIs β RFC 7946 bbox extraction and spatial pre-filtering before authenticated queries
- Mastering Geospatial Data Ingestion in Python β top-level overview covering all ingestion sources: OSM, STAC, government portals, and commercial REST APIs
- Automating Government Portal Downloads β session-cookie and form-based authentication patterns for non-ArcGIS portals