WordPress Development Recipe: Secure token-based API authentication for Shopify headless API in custom plugins
Setting Up Secure Token-Based Authentication for Shopify Headless APIs in WordPress
Integrating Shopify’s headless APIs into a WordPress site requires robust authentication. This recipe details how to implement secure, token-based authentication within a custom WordPress plugin, leveraging OAuth 2.0 for secure token exchange and management. We’ll focus on the server-side flow within WordPress, ensuring sensitive API keys and tokens are never exposed to the client.
Prerequisites and Initial Setup
Before diving into the code, ensure you have:
- A Shopify Partner account and a development store.
- A registered Shopify App (private or custom app) with the necessary API scopes (e.g.,
read_products,write_orders). Note down your App’s API Key and Secret Key. - A WordPress installation where you’ll develop your custom plugin.
Registering a Callback URL in Shopify
Shopify needs a designated URL within your WordPress site to redirect users after they authorize your app. This URL will handle the token exchange.
In your Shopify App settings, under “App URL” and “Allowed redirection URL(s)”, add a URL like: https://your-wordpress-site.com/wp-admin/admin-ajax.php?action=shopify_auth_callback. This uses WordPress’s AJAX handler for a clean, integrated callback.
WordPress Plugin Structure
We’ll create a basic plugin structure. Assume your plugin is named my-shopify-connector.
- Create a directory:
wp-content/plugins/my-shopify-connector/ - Create the main plugin file:
wp-content/plugins/my-shopify-connector/my-shopify-connector.php
Initiating the OAuth Flow
The process begins when a user (or an admin) needs to connect their Shopify store. This typically involves a button or link in the WordPress admin area that redirects to Shopify’s authorization URL.
We’ll store your Shopify App’s API Key and Secret Key as constants or in WordPress options. For this example, we’ll use constants defined in wp-config.php for better security.
Storing Credentials Securely
Add these to your wp-config.php file:
define( 'SHOPIFY_API_KEY', 'YOUR_SHOPIFY_API_KEY' ); define( 'SHOPIFY_API_SECRET', 'YOUR_SHOPIFY_API_SECRET' ); define( 'SHOPIFY_APP_SCOPES', 'read_products,write_orders' ); // Comma-separated list of scopes
Generating the Authorization URL
In your main plugin file, create a function to generate the authorization URL. This URL will redirect the user to Shopify to grant permissions.
<?php
/*
Plugin Name: My Shopify Connector
Description: Connects to Shopify Headless API using OAuth 2.0.
Version: 1.0
Author: Your Name
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// Ensure Shopify API credentials are defined
if ( ! defined( 'SHOPIFY_API_KEY' ) || ! defined( 'SHOPIFY_API_SECRET' ) || ! defined( 'SHOPIFY_APP_SCOPES' ) ) {
// In a real plugin, you might want to display an admin notice or log an error.
// For this example, we'll assume they are defined.
// error_log('Shopify API credentials are not defined in wp-config.php');
}
/**
* Generates the Shopify authorization URL.
*
* @param string $shop_domain The Shopify store domain (e.g., 'your-store.myshopify.com').
* @return string The authorization URL.
*/
function msc_get_shopify_auth_url( $shop_domain ) {
if ( empty( $shop_domain ) ) {
return '#'; // Or handle error appropriately
}
$shop_domain = sanitize_text_field( $shop_domain );
// Ensure the domain ends with .myshopify.com
if ( strpos( $shop_domain, '.myshopify.com' ) === false ) {
$shop_domain .= '.myshopify.com';
}
$callback_url = admin_url( 'admin-ajax.php?action=shopify_auth_callback' );
$auth_params = array(
'client_id' => SHOPIFY_API_KEY,
'scope' => SHOPIFY_APP_SCOPES,
'redirect_uri' => $callback_url,
);
$auth_url = add_query_arg( $auth_params, 'https://' . $shop_domain . '/admin/oauth/authorize' );
return esc_url_raw( $auth_url );
}
// Example usage: Add a button in the WordPress admin
add_action( 'admin_menu', 'msc_add_admin_menu' );
function msc_add_admin_menu() {
add_menu_page(
'Shopify Connector',
'Shopify Connector',
'manage_options',
'my-shopify-connector',
'msc_render_admin_page',
'dashicons-shopify',
80
);
}
function msc_render_admin_page() {
?>
<div class="wrap">
<h1>Shopify Connector</h1>
<p>Connect your Shopify store to WordPress.</p>
<form method="post" action="">
<table class="form-table">
<tr>
<th><label for="shopify_shop_domain">Shopify Store Domain</label></th>
<td><input type="text" id="shopify_shop_domain" name="shopify_shop_domain" value="" placeholder="your-store.myshopify.com" required></td>
</tr>
</table>
<p class="submit">
<button type="submit" name="connect_shopify" class="button button-primary">Connect to Shopify</button>
</p>
</form>
</div>
Handling the Callback and Token Exchange
When Shopify redirects back to your site, the admin-ajax.php endpoint will receive query parameters, including a code (authorization code) and the shop domain.
We need to hook into the wp_ajax_shopify_auth_callback action to process this.
<?php
// ... (previous code)
/**
* Handles the Shopify OAuth callback.
*/
add_action( 'wp_ajax_shopify_auth_callback', 'msc_handle_shopify_callback' );
function msc_handle_shopify_callback() {
// Retrieve the shop domain stored earlier
$shop_domain = get_transient( 'msc_shopify_shop_domain_for_auth_' . get_current_user_id() );
delete_transient( 'msc_shopify_shop_domain_for_auth_' . get_current_user_id() ); // Clean up transient
if ( empty( $shop_domain ) || empty( $_GET['code'] ) || empty( $_GET['shop'] ) ) {
// Handle error: missing parameters or shop domain not found
wp_die( 'Shopify authentication failed. Missing parameters or shop domain.', 'Shopify Auth Error', array( 'response' => 400 ) );
}
// Sanitize and verify the shop domain
$shop_domain_from_get = sanitize_text_field( $_GET['shop'] );
if ( $shop_domain_from_get !== $shop_domain ) {
wp_die( 'Shopify authentication failed. Mismatched shop domain.', 'Shopify Auth Error', array( 'response' => 400 ) );
}
// Ensure the domain ends with .myshopify.com
if ( strpos( $shop_domain, '.myshopify.com' ) === false ) {
$shop_domain .= '.myshopify.com';
}
$auth_code = sanitize_text_field( $_GET['code'] );
// Prepare the request to exchange the authorization code for an access token
$token_exchange_url = 'https://' . $shop_domain . '/admin/oauth/access_token';
$request_body = array(
'client_id' => SHOPIFY_API_KEY,
'client_secret' => SHOPIFY_API_SECRET,
'code' => $auth_code,
);
// Use WordPress HTTP API for the request
$response = wp_remote_post( $token_exchange_url, array(
'method' => 'POST',
'body' => $request_body,
'headers' => array(
'Content-Type' => 'application/x-www-form-urlencoded',
),
'timeout' => 60, // Adjust timeout as needed
'sslverify' => true, // Important for security
) );
if ( is_wp_error( $response ) ) {
// Handle HTTP request error
wp_die( 'Shopify authentication failed: ' . $response->get_error_message(), 'Shopify Auth Error', array( 'response' => 500 ) );
}
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
$token_data = json_decode( $response_body, true );
if ( $response_code !== 200 || ! $token_data || isset( $token_data['error'] ) ) {
// Handle API error response from Shopify
$error_message = isset( $token_data['error_description'] ) ? $token_data['error_description'] : ( isset( $token_data['error'] ) ? $token_data['error'] : 'Unknown error' );
wp_die( 'Shopify token exchange failed: ' . esc_html( $error_message ), 'Shopify Auth Error', array( 'response' => $response_code ) );
}
// Successfully obtained access token and other details
$access_token = $token_data['access_token'];
$scope = $token_data['scope']; // The scopes granted by the user
// Store the access token securely. Use WordPress options or a custom database table.
// For simplicity, we'll use options here, but consider encryption for production.
$option_name = 'msc_shopify_access_token_' . md5( $shop_domain ); // Use MD5 hash of domain as part of option name
update_option( $option_name, array(
'shop_domain' => $shop_domain,
'access_token' => $access_token,
'scope' => $scope,
'timestamp' => time(), // Store timestamp for potential token refresh logic
) );
// Redirect to a success page or back to the admin menu
wp_redirect( admin_url( 'admin.php?page=my-shopify-connector&shopify_connected=true' ) );
exit;
}
?>
Storing and Managing Access Tokens
Access tokens should be stored securely. Using WordPress options (update_option, get_option) is a common approach. For enhanced security, consider encrypting the access token before storing it, especially if it's sensitive or has write permissions.
The example above stores an array containing the shop domain, access token, granted scopes, and a timestamp. The option name is prefixed and uses an MD5 hash of the shop domain to allow storing tokens for multiple stores.
Token Expiration and Refresh
Shopify's OAuth 2.0 access tokens for custom apps are typically long-lived (e.g., 2 years). However, for public apps, tokens might expire and require refresh. Shopify's API documentation should be consulted for the specific app type and token lifecycle. If refresh tokens are provided or required, you'll need to implement a separate flow to obtain new access tokens using the refresh token.
Making Authenticated API Requests
Now that we have an access token, we can make authenticated requests to the Shopify API.
<?php
// ... (previous code)
/**
* Retrieves the stored Shopify access token for a given shop domain.
*
* @param string $shop_domain The Shopify store domain.
* @return array|false The stored token data or false if not found.
*/
function msc_get_stored_shopify_token( $shop_domain ) {
if ( empty( $shop_domain ) ) {
return false;
}
$shop_domain = sanitize_text_field( $shop_domain );
if ( strpos( $shop_domain, '.myshopify.com' ) === false ) {
$shop_domain .= '.myshopify.com';
}
$option_name = 'msc_shopify_access_token_' . md5( $shop_domain );
return get_option( $option_name, false );
}
/**
* Makes an authenticated request to the Shopify Admin API.
*
* @param string $shop_domain The Shopify store domain.
* @param string $endpoint The API endpoint (e.g., '/admin/api/2023-10/products.json').
* @param string $method HTTP method (GET, POST, PUT, DELETE).
* @param array $data Optional data for POST/PUT requests.
* @return array|WP_Error The API response data or a WP_Error object on failure.
*/
function msc_shopify_api_request( $shop_domain, $endpoint, $method = 'GET', $data = array() ) {
$token_data = msc_get_stored_shopify_token( $shop_domain );
if ( ! $token_data || empty( $token_data['access_token'] ) ) {
return new WP_Error( 'shopify_auth_error', 'Shopify access token not found or invalid.' );
}
$access_token = $token_data['access_token'];
$api_version = '2023-10'; // Use a specific API version
// Ensure the shop domain has the .myshopify.com suffix
if ( strpos( $shop_domain, '.myshopify.com' ) === false ) {
$shop_domain .= '.myshopify.com';
}
$api_url = 'https://' . $shop_domain . '/admin/api/' . $api_version . $endpoint;
$headers = array(
'X-Shopify-Access-Token' => $access_token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
);
$args = array(
'method' => strtoupper( $method ),
'headers' => $headers,
'timeout' => 60,
'sslverify' => true,
);
if ( $method === 'POST' || $method === 'PUT' || $method === 'DELETE' ) {
if ( ! empty( $data ) ) {
$args['body'] = json_encode( $data );
}
}
$response = wp_remote_request( $api_url, $args );
if ( is_wp_error( $response ) ) {
return $response; // Return WP_Error object
}
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
$decoded_body = json_decode( $response_body, true );
if ( $response_code >= 400 ) {
// Handle API errors (e.g., 401 Unauthorized, 403 Forbidden, 429 Too Many Requests)
$error_message = isset( $decoded_body['errors'] ) ? $decoded_body['errors'] : $response_body;
return new WP_Error( 'shopify_api_error', 'Shopify API Error (' . $response_code . '): ' . print_r( $error_message, true ) );
}
return $decoded_body; // Return decoded JSON response
}
// Example of how to use the API request function
function msc_get_products_from_shopify( $shop_domain ) {
$products = msc_shopify_api_request( $shop_domain, '/admin/api/2023-10/products.json', 'GET' );
if ( is_wp_error( $products ) ) {
// Handle error, e.g., display an admin notice
error_log( 'Failed to get Shopify products: ' . $products->get_error_message() );
return false;
}
// Process the $products array
if ( ! empty( $products['products'] ) ) {
foreach ( $products['products'] as $product ) {
// Do something with each product
// echo '<pre>' . print_r( $product['title'], true ) . '</pre>';
}
}
return $products;
}
// Add a button to trigger fetching products on the admin page
add_action( 'admin_notices', 'msc_display_connection_status' );
function msc_display_connection_status() {
if ( isset( $_GET['page'] ) && $_GET['page'] === 'my-shopify-connector' ) {
$connected_shop_domain = ''; // You'd typically retrieve this from options
// For demonstration, let's assume we have a stored domain
// In a real scenario, loop through stored options to find a connected store
$all_options = wp_load_alloptions();
foreach ($all_options as $key => $value) {
if (strpos($key, 'msc_shopify_access_token_') === 0 && is_array($value) && isset($value['shop_domain'])) {
$connected_shop_domain = $value['shop_domain'];
break;
}
}
if ( $connected_shop_domain ) {
echo '<div class="notice notice-success is-dismissible"><p>Successfully connected to Shopify store: ' . esc_html( $connected_shop_domain ) . '</p></div>';
echo '<p><a href="' . esc_url( add_query_arg( 'fetch_products', 'true', admin_url( 'admin.php?page=my-shopify-connector' ) ) ) . '" class="button button-secondary">Fetch Products</a></p>';
if ( isset( $_GET['shopify_connected'] ) && $_GET['shopify_connected'] === 'true' ) {
// This notice is shown after successful callback redirection
// The success notice above will also be shown.
}
} else {
echo '<div class="notice notice-warning is-dismissible"><p>Shopify store not connected.</p></div>';
}
if ( isset( $_GET['fetch_products'] ) && $_GET['fetch_products'] === 'true' && $connected_shop_domain ) {
echo '<div class="wrap"><h3>Products</h3><pre>';
$products_data = msc_get_products_from_shopify( $connected_shop_domain );
if ( is_wp_error( $products_data ) ) {
echo 'Error: ' . esc_html( $products_data->get_error_message() );
} elseif ( ! empty( $products_data['products'] ) ) {
echo esc_html( count( $products_data['products'] ) ) . ' products found:<br>';
foreach ( $products_data['products'] as $product ) {
echo '- ' . esc_html( $product['title'] ) . ' (ID: ' . esc_html( $product['id'] ) . ')<br>';
}
} else {
echo 'No products found or an empty response.';
}
echo '</pre></div>';
}
}
}
?>
Security Considerations and Best Practices
- Never expose API secrets directly in client-side code. All authentication logic should reside on the server (WordPress backend).
- Use HTTPS for all communication. This is crucial for protecting credentials and data in transit.
- Sanitize all user inputs. Especially the shop domain and any data sent to the Shopify API.
- Validate redirect URIs. Ensure that the shop domain received in the callback matches the one initiated the OAuth flow.
- Store sensitive tokens securely. Consider encrypting access tokens in the WordPress database. WordPress's built-in encryption functions (if available and configured) or a dedicated library can be used.
- Implement proper error handling. Log errors and provide informative feedback to the user without revealing sensitive details.
- Use specific API versions. Always specify the API version in your requests to avoid unexpected changes.
- Manage scopes carefully. Request only the necessary scopes for your plugin's functionality.
- Regularly review Shopify app permissions. Ensure only authorized access is granted.
Further Enhancements
- Token Refresh Logic: Implement a mechanism to refresh access tokens if they expire (especially for public apps).
- Webhooks: Set up Shopify webhooks to receive real-time updates from Shopify (e.g., product creation, order fulfillment) and process them within WordPress.
- Admin Notices: Use WordPress admin notices for clearer feedback on connection status, errors, and successful operations.
- Settings API: For a more robust plugin, utilize the WordPress Settings API to manage Shopify app settings and connected stores.
- Object Cache: Cache API responses where appropriate to reduce the number of requests to Shopify and improve performance.
- Rate Limiting: Be mindful of Shopify's API rate limits and implement strategies to handle them gracefully (e.g., exponential backoff).