WordPress JWT Authentication: Stateless API Auth Without Cookies

Cookie-based authentication is unsuitable for headless frontends and mobile apps that consume the WordPress REST API from a different origin. JSON Web Tokens (JWT) provide a stateless alternative: the client exchanges credentials for a signed token, then sends it as a Bearer header on every subsequent request. The implementation below uses only core WordPress hooks — no third-party plugin required.

Problem: A headless WordPress or WooCommerce installation needs stateless API authentication for mobile apps or JavaScript clients — session cookies and nonces do not work across origins or in non-browser environments.

Solution: Implement JWT authentication: on login, generate a signed JWT with a secret key using a library like firebase/php-jwt, return it to the client, and validate it on every subsequent request in a custom determine_current_user filter. Store the JWT secret in wp-config.php as an environment-based constant, never in the database.


The code below adds a /wp-json/auth/v1/token endpoint that issues HMAC-SHA256 signed JWTs, a custom REST authentication handler that validates the token, and a helper to refresh expiring tokens.


 'HS256', 'typ' => 'JWT' ] ) );
    $body      = jwt_base64url_encode( json_encode( $payload ) );
    $signature = jwt_base64url_encode(
        hash_hmac( 'sha256', "$header.$body", JWT_SECRET_KEY, true )
    );
    return "$header.$body.$signature";
}

function jwt_verify( string $token ): ?array {
    $parts = explode( '.', $token );
    if ( count( $parts ) !== 3 ) {
        return null;
    }
    [ $header, $body, $sig ] = $parts;
    $expected = jwt_base64url_encode(
        hash_hmac( 'sha256', "$header.$body", JWT_SECRET_KEY, true )
    );
    if ( ! hash_equals( $expected, $sig ) ) {
        return null;   // signature mismatch
    }
    $payload = json_decode( base64_decode( strtr( $body, '-_', '+/' ) ), true );
    if ( ! $payload || ( $payload['exp'] ?? 0 ) < time() ) {
        return null;   // expired
    }
    return $payload;
}

// ── Token endpoint: POST /wp-json/auth/v1/token ───────────────────────────
add_action( 'rest_api_init', function () {
    register_rest_route( 'auth/v1', '/token', [
        'methods'             => 'POST',
        'callback'            => function ( WP_REST_Request $req ) {
            $username = sanitize_user( $req->get_param( 'username' ) ?? '' );
            $password = $req->get_param( 'password' ) ?? '';

            $user = wp_authenticate( $username, $password );
            if ( is_wp_error( $user ) ) {
                return new WP_Error( 'invalid_credentials', 'Invalid username or password.', [ 'status' => 401 ] );
            }

            $payload = [
                'iss'  => get_site_url(),
                'sub'  => $user->ID,
                'iat'  => time(),
                'exp'  => time() + 3600,          // 1 hour
                'data' => [ 'roles' => $user->roles ],
            ];
            return [ 'token' => jwt_build( $payload ), 'expires_in' => 3600 ];
        },
        'permission_callback' => '__return_true',
    ] );
} );

// ── REST authentication filter: validate Bearer token ────────────────────
add_filter( 'determine_current_user', function ( $user_id ) {
    $auth = $_SERVER['HTTP_AUTHORIZATION']
         ?? ( function_exists( 'apache_request_headers' )
              ? ( apache_request_headers()['Authorization'] ?? '' )
              : '' );

    if ( ! str_starts_with( $auth, 'Bearer ' ) ) {
        return $user_id;   // not a JWT request — fall through to cookie auth
    }

    $token   = substr( $auth, 7 );
    $payload = jwt_verify( $token );
    if ( ! $payload ) {
        return new WP_Error( 'invalid_token', 'Token invalid or expired.', [ 'status' => 401 ] );
    }
    return (int) $payload['sub'];
}, 10 );


NOTE: Store AUTH_KEY (the JWT secret) in wp-config.php outside the web root and never hard-code it; rotate it immediately if it is ever exposed, since all previously issued tokens will become invalid after rotation — build a grace-period refresh flow if you need zero-downtime key rotation.